diff --git a/Cargo.lock b/Cargo.lock index 2299a9d..380fe83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,8 +644,10 @@ dependencies = [ "ferroui-vello", "ferroui-win32", "ferroui-x11", + "regex", "serde_json", "wasm-bindgen", + "zip", ] [[package]] @@ -1064,8 +1066,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "wasm-bindgen", ] [[package]] @@ -1732,6 +1736,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2656,6 +2672,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -3563,6 +3585,21 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f5b58784007ff6e5f280ec82c8596686a3b9b5dd5836c63288d4479f84728b9" +dependencies = [ + "crc32fast", + "getrandom", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/README.md b/README.md index ab4f33d..0c8aa10 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ browser (WebAssembly): the same editor, controls, document model and scene rende **Open it in the browser:** https://ferroui.github.io/FerroDesign/ -![FerroDesign editing the Orbit mobile banking document in the browser](docs/images/ferrodesign-editor.png) +![FerroDesign inspecting two selected banking screens with shared and mixed property values](docs/images/ferrodesign-editor.png) The editor has pages and layers, an infinite canvas, layout guides, editable vector networks, rich text with paragraph formatting, image cropping, effects and masks, continuous corners, variable-width @@ -73,8 +73,9 @@ cargo test --locked -p ferrodesign-app --lib -- --test-threads=1 node --test tests/*.test.mjs ``` -The browser validation scenarios drive the built site in Chromium with Playwright -(`npm ci && node tests/browser.mjs`, see `scripts/browser_ci.py` for the groups); they are not part of CI. +Run `npm ci && node tests/browser.mjs` to drive the built site in Chromium. Pull requests run all +scenario groups in Software2D, the WebGL2 smoke group, and the browser storage checks. The full +matrix also runs on the nightly schedule. See [the scenario groups](scripts/browser-ci-groups.json). ## License diff --git a/crates/ferrodesign-app/Cargo.toml b/crates/ferrodesign-app/Cargo.toml index 54451eb..591acae 100644 --- a/crates/ferrodesign-app/Cargo.toml +++ b/crates/ferrodesign-app/Cargo.toml @@ -15,6 +15,8 @@ name = "ferrodesign-app" path = "src/main.rs" [dependencies] +regex = "1" +zip = { version = "9.2.0", default-features = false } ferrodesign-core = { path = "../ferrodesign-core" } ferroui-base = { path = "../../FerroUI/src/FerroUI.Base", features = ["markup-functions"] } ferroui-controls = { path = "../../FerroUI/src/FerroUI.Controls" } diff --git a/crates/ferrodesign-app/src/app.rs b/crates/ferrodesign-app/src/app.rs index 95690d9..e96223f 100644 --- a/crates/ferrodesign-app/src/app.rs +++ b/crates/ferrodesign-app/src/app.rs @@ -40,11 +40,11 @@ impl ApplicationImpl for App { .and_then(|l| l.as_classic_desktop_style_application_lifetime()) { let window = Window::new(); - window.set_title(Some("FerroDesign · Orbit".into())); + window.set_title(Some("FerroDesign".into())); window.set_width(1500.0); window.set_height(960.0); - window.set_min_width(980.0); - window.set_min_height(650.0); + window.set_min_width(360.0); + window.set_min_height(420.0); window.set_content(Some(Control::boxed(shell.root.clone()))); let weak = Rc::downgrade(&shell); window.closing(move |event| { diff --git a/crates/ferrodesign-app/src/canvas.rs b/crates/ferrodesign-app/src/canvas.rs index a2abd33..3f6731b 100644 --- a/crates/ferrodesign-app/src/canvas.rs +++ b/crates/ferrodesign-app/src/canvas.rs @@ -1,13 +1,23 @@ //! Interactive retained-mode surface. Artwork uses only FerroUI's public DrawingContext API. +mod drawing; +mod drag_copy; +mod measure; +mod resize; +mod rotation; +mod selection_resize; +mod line_endpoint; mod network; mod network_bucket; mod grids; +mod rulers; mod image_crop; mod layout_drag; mod selection; mod input_observations; mod vector_width; +pub(crate) mod scale; mod corner_radius; +mod slices; mod gradient_edit; use crate::{ icons::brush, @@ -18,7 +28,7 @@ use ferrodesign_core::{ Point, Rect, VectorPath, Viewport, }; use ferroui_base::input::{ - InputElement, InputElementImpl, InputElementImplExt, Key, KeyEventArgs, KeyModifiers, + FocusChangedEventArgs, InputElement, InputElementImpl, InputElementImplExt, Key, KeyEventArgs, KeyModifiers, PointerCaptureLostEventArgs, PointerEventArgs, PointerPressedEventArgs, PointerReleasedEventArgs, PointerWheelEventArgs, }; @@ -44,10 +54,12 @@ use std::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { Select, + Scale, Frame, Rectangle, Ellipse, Line, + Arrow, Polygon, Star, Pen, @@ -56,15 +68,18 @@ pub enum Tool { Hand, Comment, Section, + Slice, } impl Tool { pub fn name(self) -> &'static str { match self { Self::Select => "Select", + Self::Scale => "Scale", Self::Frame => "Frame", Self::Rectangle => "Rectangle", Self::Ellipse => "Ellipse", Self::Line => "Line", + Self::Arrow => "Arrow", Self::Polygon => "Polygon", Self::Star => "Star", Self::Pen => "Pen", @@ -73,18 +88,20 @@ impl Tool { Self::Hand => "Hand", Self::Comment => "Comment", Self::Section => "Section", + Self::Slice => "Slice", } } fn kind(self) -> NodeKind { match self { Self::Frame => NodeKind::Frame, Self::Ellipse => NodeKind::Ellipse, - Self::Line => NodeKind::Line, + Self::Line | Self::Arrow => NodeKind::Line, Self::Polygon => NodeKind::Polygon, Self::Star => NodeKind::Star, Self::Pen | Self::Pencil => NodeKind::Vector, Self::Text => NodeKind::Text, Self::Section => NodeKind::Section, + Self::Slice => NodeKind::Slice, _ => NodeKind::Rectangle, } } @@ -97,6 +114,7 @@ pub enum CanvasEvent { Selection, Document, Draft, + ResizePreview, Camera, Tool, EditText(NodeId), @@ -113,10 +131,7 @@ pub enum CanvasEvent { } enum Gesture { None, - Pan { - start: Point, - original: Point, - }, + Pan { previous: Point }, Move { start: Point, nodes: Vec, @@ -124,24 +139,16 @@ enum Gesture { exclude: Vec, moved: bool, }, - Resize { - node: Node, - corner: usize, - }, - Rotate { - node: Node, - center: Point, - angle: f64, - }, - Marquee { - start: Point, - current: Point, - additive: bool, - }, - Draw { - start: Point, - id: NodeId, - }, + Scale(scale::ScaleDrag), + EdgePress { hit: resize::Hit, world: Point, screen: Point, modifiers: KeyModifiers, clicks: i32 }, + Resize(resize::ResizeDrag), + MultiResize(selection_resize::MultiResize), + Rotate(rotation::Drag), + RotationOrigin(rotation::OriginDrag), + Marquee(selection::Marquee), + AltDrag(Box), + Draw(drawing::ShapeDrag), + LineEndpoint(line_endpoint::EndpointDrag), Pencil { id: NodeId, origin: Point, @@ -169,14 +176,28 @@ pub struct DesignCanvas { pub viewport: Cell, pub tool: Cell, gesture: RefCell, + manipulation_cursor: Cell>, + hovered_resize_edge: Cell>, + size_badge: RefCell>, + size_badge_obstacle: Cell>, + scale_bounds: RefCell>, + rotation: rotation::RotationState, painter: RefCell, callback: RefCell>>, hover: Cell>, + measurements: measure::Measurements, + layer_preview: Cell>, prototype_press: Cell>, space: Cell, initialized: Cell, + pub show_outlines: Cell, + pub outline_hidden: Cell, + pub outline_bounds: Cell, + outlines: RefCell, pub show_grid: Cell, + pub(crate) nudge: Cell, pub show_rulers: Cell, + rulers: RefCell, pub dark: Cell, pub presentation: Cell>, pub overlays: RefCell>, @@ -185,6 +206,7 @@ pub struct DesignCanvas { pub snap: Cell, snap_guides: Cell<(Option, Option)>, pen_node: Cell>, + creation_parent: Cell>, pen_tangent: Cell>, pub vector_node: Cell>, pub vector_point: Cell>, @@ -217,14 +239,28 @@ impl DesignCanvas { viewport: Cell::new(Viewport::default()), tool: Cell::new(Tool::Select), gesture: RefCell::new(Gesture::None), + manipulation_cursor: Cell::new(None), + hovered_resize_edge: Cell::new(None), + size_badge: RefCell::default(), + size_badge_obstacle: Cell::default(), + scale_bounds: RefCell::default(), + rotation: Default::default(), painter: RefCell::new(ScenePainter::default()), callback: RefCell::new(None), hover: Cell::new(None), + measurements: Default::default(), + layer_preview: Cell::new(None), prototype_press: Cell::new(None), space: Cell::new(false), initialized: Cell::new(false), + show_outlines: Cell::new(false), + outline_hidden: Cell::new(false), + outline_bounds: Cell::new(true), + outlines: RefCell::default(), show_grid: Cell::new(false), + nudge: Cell::new(crate::platform::NudgeAmounts::load()), show_rulers: Cell::new(false), + rulers: RefCell::default(), dark: Cell::new(false), presentation: Cell::new(None), overlays: RefCell::new(Vec::new()), @@ -233,6 +269,7 @@ impl DesignCanvas { snap: Cell::new(true), snap_guides: Cell::new((None, None)), pen_node: Cell::new(None), + creation_parent: Cell::new(None), pen_tangent: Cell::new(None), vector_node: Cell::new(None), vector_point: Cell::new(None), @@ -293,6 +330,8 @@ impl DesignCanvas { } /// Activation changes cannot reuse a press or hover from an earlier frame. pub fn reset_presentation_input(&self) { + self.clear_measurement(); + self.set_resize_hover(None); self.leave_linear_gradient_edit(); self.cancel_corner_radius_gesture(); self.clear_corner_radius_hover(); @@ -302,7 +341,25 @@ impl DesignCanvas { self.hover.set(None); self.space.set(false); } + pub(crate) fn preview_layer(&self, id: Option) { + if self.layer_preview.replace(id) != id { + self.invalidate_visual(); + } + } + + pub(crate) fn end_layer_preview(&self, id: NodeId) { + if self.layer_preview.get() == Some(id) { + self.preview_layer(None); + } + } + + pub(crate) fn highlighted_layer(&self) -> Option { + self.layer_preview.get().or(self.hover.get()) + } + pub fn set_tool(&self, tool: Tool) { + self.forget_selected_ruler(); + self.clear_measurement(); self.leave_linear_gradient_edit(); self.cancel_corner_radius_gesture(); self.clear_corner_radius_hover(); @@ -312,15 +369,108 @@ impl DesignCanvas { self.cancel_layout_drag(); self.finish_image_crop(); self.finish_pen(); + if self.tool.get() != tool && !matches!(*self.gesture.borrow(), Gesture::None) { + self.cancel_active_edit(); + } if tool != Tool::Select { self.leave_vector_edit(); } + self.set_resize_hover(None); self.tool.set(tool); + self.hover.set(None); self.focus(); self.invalidate_visual(); self.emit(CanvasEvent::Tool); } + pub(crate) fn has_pointer_gesture(&self) -> bool { + self.guide_drag_active() || !matches!(*self.gesture.borrow(), Gesture::None) + } + + pub(crate) fn editor_shortcut(&self, e: &KeyEventArgs) -> Option<&'static str> { + let command = e.key_modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META); + let shift = e.key_modifiers.contains(KeyModifiers::SHIFT); + let alt = e.key_modifiers.contains(KeyModifiers::ALT); + if command { + match e.key { + Key::Add | Key::OemPlus if !alt => Some("zoom-in"), + Key::Subtract | Key::OemMinus if !alt => Some("zoom-out"), + Key::Z => Some(if shift { "redo" } else { "undo" }), + Key::Y => Some("redo"), + Key::C => Some("copy"), + Key::X => Some("cut"), + Key::V => Some("paste"), + Key::D => Some("duplicate"), + Key::A => Some(if shift { "select-inverse" } else { "select-all" }), + Key::G => Some(if alt { + "frame-selection" + } else if shift { + "ungroup" + } else { + "group" + }), + Key::S => Some("save"), + Key::O if shift && !alt => Some("show-outlines"), + Key::O => Some("open"), + Key::F if !alt && !shift => Some("find-layers"), + Key::R if !alt && !shift => Some("rename-layer"), + Key::OemQuestion if !alt && !shift => Some("actions"), + Key::K => Some(if shift { + "place-image" + } else if alt { + "component" + } else { + "actions" + }), + _ => None, + } + } else { + match e.key { + Key::Delete | Key::Back => Some("delete"), + Key::F2 => Some("rename-layer"), + Key::V => Some("select"), + Key::F => Some(if shift { "section" } else { "frame" }), + Key::R if !alt => Some(if shift { "rulers" } else { "rectangle" }), + Key::O => Some("ellipse"), + Key::L => Some(if shift { "arrow" } else { "line" }), + Key::P => Some(if shift { "pencil" } else { "pen" }), + Key::B if shift && self.vector_node.get().is_some() => Some("network-bucket"), + Key::T => Some("text"), + Key::H => Some("hand"), + Key::C => Some("comment"), + Key::A if shift => Some("auto-layout"), + Key::S => Some(if shift { "section" } else { "slice" }), + Key::Add | Key::OemPlus if !alt => Some("zoom-in"), + Key::Subtract | Key::OemMinus if !alt => Some("zoom-out"), + Key::D0 if shift && !alt => Some("actual-size"), + Key::D1 => Some("fit"), + Key::D2 => Some("fit-selection"), + Key::K if !shift && !alt => Some("scale"), + _ => None, + } + } + } + + pub(crate) fn page_camera(&self) -> Option { + self.initialized.get().then(|| self.viewport.get()) + } + + pub(crate) fn restore_page_camera(&self, camera: Option) { + self.reset_fit(); + let current = self.viewport.get(); + let mut restored = camera.unwrap_or_default(); + if camera.is_some() { + // After a window or phone rotation, keep the same world point at center. + restored.pan.x += (current.width - restored.width) * 0.5; + restored.pan.y += (current.height - restored.height) * 0.5; + self.initialized.set(true); + } + restored.width = current.width; + restored.height = current.height; + self.viewport.set(restored); + } + pub fn reset_fit(&self) { + self.clear_measurement(); self.cancel_linear_gradient_gesture(); self.clear_gradient_hover(); self.cancel_corner_radius_gesture(); @@ -329,19 +479,59 @@ impl DesignCanvas { self.initialized.set(false); self.invalidate_visual(); } - pub fn zoom(&self, factor: f64) { + pub fn set_zoom(&self, zoom: f64) { self.cancel_linear_gradient_gesture(); self.clear_gradient_hover(); self.cancel_corner_radius_gesture(); self.clear_corner_radius_hover(); self.cancel_layout_drag(); let mut v = self.viewport.get(); - v.zoom_at(Point::new(v.width / 2.0, v.height / 2.0), v.zoom * factor); - self.viewport.set(v); - self.invalidate_visual(); - self.emit(CanvasEvent::Camera); + v.zoom_at(Point::new(v.width / 2.0, v.height / 2.0), zoom); + self.update_camera(v); + } + pub fn zoom(&self, factor: f64) { + self.set_zoom(self.viewport.get().zoom * factor); + } + pub(crate) fn update_camera(&self, viewport: Viewport) { + if self.viewport.replace(viewport) != viewport { + self.clear_measurement(); + self.set_resize_hover(None); + self.invalidate_visual(); + self.emit(CanvasEvent::Camera); + } + } + // Pointer movement and a modifier change at a stationary pointer must agree + // on the target. Geometry handles own their hit area before layer measurement. + fn hover_edit_handle(&self, world: Point, modifiers: KeyModifiers) -> bool { + if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) && self.hover_rotation(world) { return true; } + let can_resize = self.resize_with_modifiers(modifiers); + if can_resize && self.multi_resize_hover(world) { return true; } + let resize = can_resize + .then(|| self.handle_at(world)).flatten(); + self.set_resize_hover(resize); + if resize.is_some() { + self.clear_corner_radius_hover(); + } else if !self.corner_radius_hover(world, modifiers) { + return false; + } + self.clear_measurement(); + if self.hover.take().is_some() { self.invalidate_visual(); } + true + } + fn pan_pointer(&self, screen: Point) -> bool { + // A captured pan is independent of the document. In particular, do not + // run vector/gradient hover probes while dragging over their handles. + let delta = match &mut *self.gesture.borrow_mut() { + Gesture::Pan { previous } => screen - std::mem::replace(previous, screen), + _ => return false, + }; + let mut viewport = self.viewport.get(); + viewport.pan = viewport.pan + delta; + self.update_camera(viewport); + true } pub fn zoom_to_selection(&self) { + self.clear_measurement(); self.cancel_linear_gradient_gesture(); self.clear_gradient_hover(); self.cancel_corner_radius_gesture(); @@ -369,25 +559,33 @@ impl DesignCanvas { self.painter.borrow_mut().register_image_data(href) } pub fn export_png(&self) -> Result, String> { + let selection = self.editor.borrow().selection.clone(); + self.export_png_for(&selection) + } + + pub fn export_png_for(&self, selection: &[NodeId]) -> Result, String> { use ferroui_base::media::imaging::{ BitmapEncoderOptions, PngBitmapEncoderOptions, RenderTargetBitmap, }; use ferroui_base::PixelSize; let editor = self.editor.borrow(); - let roots = if editor.selection.is_empty() { - editor - .document - .page(editor.active_page) - .map(|p| p.children.clone()) - .unwrap_or_default() + let doc = &editor.document; + let slice = doc.export_slice(editor.active_page, selection); + if let [id] = selection { + if doc.nodes.get(id).is_some_and(|node| node.kind == NodeKind::Slice) && slice.is_none() { + return Err("This slice has empty or singular bounds.".into()); + } + } + let roots = if slice.is_some() || selection.is_empty() { + doc.page(editor.active_page).map(|p| p.children.clone()).unwrap_or_default() } else { - editor.root_selection() + editor.root_selection_for(selection) + }; + let bounds = match slice { + Some(slice) => slice.local, + None => self.painter.borrow_mut().rendered_editor_bounds(&editor, &roots) + .ok_or_else(|| "There are no layers to export.".to_string())?, }; - let bounds = self - .painter - .borrow_mut() - .rendered_editor_bounds(&editor, &roots) - .ok_or_else(|| "There are no layers to export.".to_string())?; let width = bounds.width.ceil().max(1.0); let height = bounds.height.ceil().max(1.0); if width > 16384.0 || height > 16384.0 || width * height > 32_000_000.0 { @@ -396,14 +594,17 @@ impl DesignCanvas { let bitmap = RenderTargetBitmap::new(PixelSize::new(width as i32, height as i32)); { let mut context = bitmap.create_drawing_context(); - let transform = - context.push_transform(Matrix::create_translation(-bounds.x, -bounds.y)); + let clip = slice.map(|slice| context.push_clip(rect(slice.local))); + let matrix = slice.map_or_else(|| Matrix::create_translation(-bounds.x, -bounds.y), + |s| crate::renderer::ui_matrix(s.world_to_local)); + let transform = context.push_transform(matrix); let mut painter = self.painter.borrow_mut(); // This export has one output pixel per document unit. Canvas zoom // and the window's display density must not affect the saved PNG. painter.set_raster_scale(1.0); - painter.render_editor(&mut context, &editor, &roots, bounds); + painter.render_editor(&mut context, &editor, &roots, slice.map_or(bounds, |s| s.world_bounds)); context.pop(transform); + if let Some(clip) = clip { context.pop(clip); } context.dispose(); } let mut bytes = Vec::new(); @@ -431,7 +632,7 @@ impl DesignCanvas { if let Some(id) = self.pen_node.take() { let mut editor = self.editor.borrow_mut(); normalize_drawn_vector(&mut editor, id); - editor.commit_transaction(); + self.commit_created_layer(&mut editor, id); drop(editor); self.invalidate_visual(); self.emit(CanvasEvent::Document); @@ -440,6 +641,11 @@ impl DesignCanvas { /// Undo during a live pointer edit cancels that transaction and its gesture. /// Later pointer events must never continue writing after history restored it. pub fn cancel_active_edit(&self) -> bool { + if self.cancel_guide_drag() { return true; } + if matches!(*self.gesture.borrow(), Gesture::Pan { .. }) { + *self.gesture.borrow_mut() = Gesture::None; + return true; + } if self.cancel_linear_gradient_gesture() { return true; } if self.cancel_corner_radius_gesture() { return true; } if self.cancel_stroke_width_gesture() { return true; } @@ -454,6 +660,39 @@ impl DesignCanvas { active } pub fn cancel(&self) { + if self.cancel_guide_drag() { return; } + self.clear_measurement(); + if matches!(*self.gesture.borrow(), Gesture::Rotate(_) | Gesture::RotationOrigin(_)) { + match self.gesture.replace(Gesture::None) { + Gesture::Rotate(drag) => drag.cancel(self), + Gesture::RotationOrigin(drag) => drag.cancel(self), + _ => unreachable!(), + } + self.set_resize_hover(None); + return; + } + if matches!(*self.gesture.borrow(), Gesture::MultiResize(_) | Gesture::Resize(_)) { + match self.gesture.replace(Gesture::None) { + Gesture::MultiResize(drag) => drag.cancel(self), + Gesture::Resize(drag) => drag.cancel(self), + _ => unreachable!(), + } + return; + } + if matches!(*self.gesture.borrow(), Gesture::Marquee(_) | Gesture::AltDrag(_)) { + *self.gesture.borrow_mut() = Gesture::None; + self.set_resize_hover(None); + self.snap_guides.set((None, None)); + self.layout_grids.borrow_mut().clear_snap(); + self.invalidate_visual(); + return; + } + if matches!(*self.gesture.borrow(), Gesture::EdgePress { .. }) { + *self.gesture.borrow_mut() = Gesture::None; + self.set_resize_hover(None); + return; + } + if self.cancel_scale_drag() { return; } self.layout_grids.borrow_mut().clear_snap(); if self.cancel_linear_gradient_gesture() { return; } self.clear_gradient_hover(); @@ -466,6 +705,7 @@ impl DesignCanvas { if self.cancel_layout_drag() { return; } self.cancel_image_crop(); self.editor.borrow_mut().cancel_transaction(); + self.creation_parent.set(None); self.pen_node.set(None); self.pen_tangent.set(None); *self.gesture.borrow_mut() = Gesture::None; @@ -688,42 +928,22 @@ impl DesignCanvas { p } } - fn handle_at(&self, p: Point) -> Option { - let editor = self.editor.borrow(); - if editor.selection.len() != 1 { - return None; - } - let id = editor.selection[0]; - let node = editor.document.nodes.get(&id)?; - if node.locked { - return None; - } - let transform = editor.document.world_transform(id)?; - let corners = node.local_bounds().corners().map(|p| transform.apply(p)); - let tolerance = 8.0 / self.viewport.get().zoom; - for (i, c) in corners.iter().enumerate() { - if c.distance(p) < tolerance { - return Some(i); - } - } - let top = (corners[0] + corners[1]) * 0.5; - let center = transform.apply(Point::new(node.width / 2.0, node.height / 2.0)); - let direction = top - center; - let length = direction.distance(Point::new(0.0, 0.0)).max(0.001); - if (top + direction * (24.0 / self.viewport.get().zoom / length)).distance(p) < tolerance { - return Some(4); - } - None - } - fn new_node(&self, tool: Tool, p: Point) -> NodeId { + fn new_node(&self, tool: Tool, p: Point) -> Option { + let destination = self.drawing_parent_at(p, tool); let mut editor = self.editor.borrow_mut(); editor.begin_transaction(format!("Draw {}", tool.name())); let mut node = Node::new(tool.kind(), tool.name(), p.x, p.y, 1.0, 1.0) - .with_fill(Color::rgb(123, 97, 255)); + .with_fill(Color::rgb(217, 217, 217)); if tool == Tool::Frame { node = node.with_fill(Color::WHITE); node.clip_content = true; } + if tool == Tool::Slice { + node.fills.clear(); + let last = editor.document.nodes.values().filter(|n| n.kind == NodeKind::Slice) + .filter_map(|n| n.name.strip_prefix("Slice ")?.parse::().ok()).max().unwrap_or(0); + node.name = format!("Slice {}", u64::from(last) + 1); + } if tool == Tool::Section { node = node.with_fill(Color::rgba(216, 209, 255, 48)); node.width = 400.0; @@ -734,26 +954,41 @@ impl DesignCanvas { node.width = 160.0; node.height = 36.0; } - if matches!(tool, Tool::Line | Tool::Pen | Tool::Pencil) { + if matches!(tool, Tool::Line | Tool::Arrow | Tool::Pen | Tool::Pencil) { node.fills.clear(); - node.strokes = vec![core::Stroke::new(Color::rgb(30, 30, 30), 2.0)]; + let mut stroke = if node.kind == NodeKind::Line { + core::Stroke::new(Color::BLACK, 1.0) + } else { + core::Stroke::new(Color::rgb(30, 30, 30), 2.0) + }; + if tool == Tool::Arrow { + stroke.cap = core::StrokeCap::Arrow; + } + node.strokes = vec![stroke]; } if tool == Tool::Star { node.polygon_sides = 5; } let parent = editor.active_page; - let id = editor - .document - .insert_node(parent, node) - .expect("valid page"); + let id = match editor.document.insert_node(parent, node) { + Ok(id) => id, + Err(error) => { + editor.cancel_transaction(); + drop(editor); + self.emit(CanvasEvent::Notice(error.to_string())); + return None; + } + }; + self.creation_parent.set(destination.map(|parent| (id, parent))); editor.selection = vec![id]; editor.refresh(); - id + Some(id) } } impl InputElementImpl for DesignCanvas { fn on_pointer_pressed(this: &Self, e: &PointerPressedEventArgs) { Self::parent_on_pointer_pressed(this, e); + this.clear_measurement(); this.layout_grids.borrow_mut().clear_snap(); this.cancel_linear_gradient_gesture(); this.cancel_corner_radius_gesture(); @@ -768,7 +1003,16 @@ impl InputElementImpl for DesignCanvas { let world = this.viewport.get().screen_to_world(screen); this.pointer_presses.borrow_mut().record(e, screen, world, this.viewport.get(), this.editor.borrow().selection.len(), this.frames.get()); + if matches!(*this.gesture.borrow(), Gesture::Scale(_) | Gesture::MultiResize(_) | Gesture::Rotate(_) | Gesture::RotationOrigin(_)) { return; } + if this.guide_drag_active() { return; } if e.properties().is_right_button_pressed { + if this.show_rulers.get() && this.presentation.get().is_none() { + if let Some(id) = this.guide_at(screen) { + this.select_ruler_guide(Some(id)); + this.emit(CanvasEvent::Action("ruler-guide-settings")); + return; + } + } if this.presentation.get().is_none() { this.emit(CanvasEvent::ContextMenu); } @@ -787,109 +1031,52 @@ impl InputElementImpl for DesignCanvas { || this.tool.get() == Tool::Hand || e.properties().is_middle_button_pressed { - *this.gesture.borrow_mut() = Gesture::Pan { - start: screen, - original: this.viewport.get().pan, - }; + this.set_resize_hover(None); + *this.gesture.borrow_mut() = Gesture::Pan { previous: screen }; + if this.hover.take().is_some() { this.invalidate_visual(); } return; } + if this.press_ruler(screen, e.pointer().id(), e.key_modifiers(), true) { return; } if this.linear_gradient_press(world, e.click_count()) { return; } if this.image_crop_press(world) { return; } if this.stroke_width_press(world) { return; } let additive = e.key_modifiers().contains(KeyModifiers::SHIFT); match this.tool.get() { Tool::Comment => this.emit(CanvasEvent::Comment(world)), - Tool::Select => { + Tool::Select | Tool::Scale => { + let scaling = this.tool.get() == Tool::Scale; let deep = e.key_modifiers().intersects(KeyModifiers::CONTROL | KeyModifiers::META); - if this.corner_radius_press(world, e.key_modifiers()) { return; } + if scaling && !deep && this.press_scale_handle(world) { return; } + if !deep && this.press_rotation(world, screen, e.key_modifiers(), e.click_count()) { return; } + let can_resize = this.resize_with_modifiers(e.key_modifiers()); + if can_resize && this.press_multi_resize(world, screen, e.key_modifiers(), e.click_count()) { return; } + if !scaling && this.corner_radius_press(world, e.key_modifiers()) { return; } this.clear_corner_radius_hover(); - if e.key_modifiers().contains(KeyModifiers::ALT) && !deep && this.vector_node.get().is_none() { - this.cycle_selection_at(world, if additive { core::SelectionCycle::InFront } else { core::SelectionCycle::Behind }); - return; - } + let handle = if scaling || !can_resize { None } else { this.handle_at(world) }; + if handle.is_none() && !deep && this.press_ruler(screen, e.pointer().id(), e.key_modifiers(), false) { return; } + this.forget_selected_ruler(); + if handle.is_none() && this.press_alt_drag(world, screen, e.key_modifiers()) { return; } if this.vector_press(world, e.click_count(), additive) { return; } - if let Some(corner) = if deep { None } else { this.handle_at(world) } { + if let Some(hit) = handle { let mut editor = this.editor.borrow_mut(); let id = editor.selection[0]; - let node = editor.document.nodes[&id].clone(); - if corner == 4 { - let center = editor - .document - .world_transform(id) - .unwrap() - .apply(Point::new(node.width / 2.0, node.height / 2.0)); - let angle = (world.y - center.y).atan2(world.x - center.x).to_degrees(); - editor.begin_node_transaction(&[id], "Rotate"); - *this.gesture.borrow_mut() = Gesture::Rotate { - node, - center, - angle, - }; - } else { - editor.begin_transaction("Resize"); - *this.gesture.borrow_mut() = Gesture::Resize { node, corner }; - } - } else if let Some(id) = this.pick_with_modifiers(world, e.key_modifiers(), e.click_count() == 2) { - let mut editor = this.editor.borrow_mut(); - if additive { - if editor.selection.contains(&id) { - editor.selection.retain(|x| *x != id); - } else { - editor.selection.push(id); + let node = &editor.document.nodes[&id]; + if node.kind == NodeKind::Line { + if let Some(drag) = line_endpoint::EndpointDrag::new(&editor, node.clone(), hit.handle == resize::Handle::Corner(0), world) { + editor.begin_node_transaction(&[id], "Move line endpoint"); + *this.gesture.borrow_mut() = Gesture::LineEndpoint(drag); } - } else if !editor.selection.contains(&id) { - editor.selection = vec![id]; - } - if e.click_count() == 2 && editor.document.nodes[&id].kind == NodeKind::Text { - drop(editor); - this.emit(CanvasEvent::EditText(id)); - return; - } - if e.click_count() == 2 && editor.document.nodes[&id].kind == NodeKind::Vector { - drop(editor); - this.edit_vector(); - return; - } - if this.layout_drag_press(&editor, id, world, screen) { - drop(editor); - this.emit(CanvasEvent::Selection); - this.invalidate_visual(); - return; - } - let exclude = editor.root_selection(); - let bounds = exclude - .iter() - .filter_map(|&id| editor.document.world_bounds(id)) - .reduce(|a, b| a.union(b)); - let nodes = exclude - .iter() - .filter_map(|id| editor.document.nodes.get(id).cloned()) - .collect(); - editor.begin_node_transaction(&exclude, "Move layers"); - if let Some(probe) = this.free_layout_probe.borrow_mut().as_mut() { - probe.record_transaction(&editor); + } else if deep || matches!(hit.handle, resize::Handle::Edge(_)) { + *this.gesture.borrow_mut() = Gesture::EdgePress { + hit, world, screen, modifiers: e.key_modifiers(), clicks: e.click_count(), + }; + } else if let Some(drag) = resize::ResizeDrag::new(&mut editor, hit, world) { + *this.gesture.borrow_mut() = Gesture::Resize(drag); } - *this.gesture.borrow_mut() = Gesture::Move { - start: world, - nodes, - bounds, - exclude, - moved: false, - }; - drop(editor); - this.emit(CanvasEvent::Selection); } else { - if !additive { - this.editor.borrow_mut().selection.clear(); - } - *this.gesture.borrow_mut() = Gesture::Marquee { - start: world, - current: world, - additive, - }; - this.emit(CanvasEvent::Selection); + this.select_at_pointer(world, e.key_modifiers(), e.click_count(), Some(screen)); } } Tool::Pen => { @@ -903,7 +1090,7 @@ impl InputElementImpl for DesignCanvas { let id = if let Some(id) = this.pen_node.get() { id } else { - let id = this.new_node(Tool::Pen, p); + let Some(id) = this.new_node(Tool::Pen, p) else { return }; this.pen_node.set(Some(id)); id }; @@ -974,13 +1161,8 @@ impl InputElementImpl for DesignCanvas { if let Some(snap) = this.layout_grids.borrow_mut().snap(&editor, &[start], 5.0 / this.viewport.get().zoom, &[], None) { start = start + snap.delta; } } - let id = this.new_node(tool, start); - if tool == Tool::Text { - this.editor.borrow_mut().commit_transaction(); - this.tool.set(Tool::Select); - this.emit(CanvasEvent::Document); - this.emit(CanvasEvent::EditText(id)); - } else if tool == Tool::Pencil { + let Some(id) = this.new_node(tool, start) else { return }; + if tool == Tool::Pencil { this.editor .borrow_mut() .document @@ -994,7 +1176,7 @@ impl InputElementImpl for DesignCanvas { }); *this.gesture.borrow_mut() = Gesture::Pencil { id, origin: start }; } else { - *this.gesture.borrow_mut() = Gesture::Draw { start, id }; + *this.gesture.borrow_mut() = Gesture::Draw(drawing::ShapeDrag::new(id, tool, world, start)); } this.emit(CanvasEvent::Selection); } @@ -1004,11 +1186,25 @@ impl InputElementImpl for DesignCanvas { fn on_pointer_moved(this: &Self, e: &PointerEventArgs) { Self::parent_on_pointer_moved(this, e); let screen = e.get_position(Some(this)); - this.selection_pointer.set(Some(Point::new(screen.x, screen.y))); + let position = Point::new(screen.x, screen.y); + this.selection_pointer.set(Some(position)); + if this.move_ruler(position, e.pointer().id(), e.key_modifiers()) { e.set_handled(true); return; } + if this.presentation.get().is_none() { + if this.pan_pointer(position) { + e.set_handled(true); + return; + } + if (this.space.get() || this.tool.get() == Tool::Hand) + && matches!(*this.gesture.borrow(), Gesture::None) + && this.layout_drag.borrow().is_none() + && this.image_crop_gesture.borrow().is_none() + { + return; + } + } let world = this.world(screen); if this.presentation.get().is_some() { this.emit(CanvasEvent::PrototypePointerMoved(Point::new(screen.x, screen.y))); - let world = this.world(screen); let hover = this.prototype_pick(world); let previous = this.hover.replace(hover); if previous != hover { @@ -1027,20 +1223,27 @@ impl InputElementImpl for DesignCanvas { } return; } - if this.linear_gradient_move(world) { return; } + if this.linear_gradient_move(world) { this.set_resize_hover(None); return; } if this.corner_radius_move(world) { return; } - if this.image_crop_move(world) { return; } - if this.stroke_width_move(world) { return; } + if this.image_crop_move(world) { this.set_resize_hover(None); return; } + if this.stroke_width_move(world) { this.set_resize_hover(None); return; } if this.network_bucket_move(world, e.key_modifiers().contains(KeyModifiers::SHIFT)) { return; } let shift = e.key_modifiers().contains(KeyModifiers::SHIFT); if this.layout_drag_move_constrained(world, Point::new(screen.x, screen.y), shift) { return; } - if this.corner_radius_hover(world, e.key_modifiers()) { - if this.hover.take().is_some() { this.invalidate_visual(); } - return; + if matches!(*this.gesture.borrow(), Gesture::None) { + if this.measure_hover(world, e.key_modifiers()) { return; } + if this.tool.get() == Tool::Scale { + this.update_scale_cursor(world); + } else if this.hover_edit_handle(world, e.key_modifiers()) { + return; + } } - let mut camera_changed = false; let mut gesture = this.gesture.borrow_mut(); + this.activate_edge_drag(&mut gesture, position); + let resize_revision = matches!(*gesture, Gesture::Resize(_) | Gesture::MultiResize(_) | Gesture::Rotate(_)) + .then(|| this.editor.borrow().revision); match &mut *gesture { + Gesture::EdgePress { .. } => return, Gesture::None => { let hover = this.pick_with_modifiers(world, e.key_modifiers(), false); if this.hover.replace(hover) != hover { @@ -1048,204 +1251,17 @@ impl InputElementImpl for DesignCanvas { } return; } - Gesture::Pan { start, original } => { - let mut v = this.viewport.get(); - v.pan = *original + Point::new(screen.x - start.x, screen.y - start.y); - this.viewport.set(v); - camera_changed = true; - } - Gesture::Move { - start, - nodes, - bounds, - exclude, - moved, - } => { - let mut delta = world - *start; - if delta.distance(Point::new(0.0, 0.0)) < 0.5 / this.viewport.get().zoom && !*moved - { - return; - } - if shift { - if delta.x.abs() > delta.y.abs() { - delta.y = 0.0 - } else { - delta.x = 0.0 - } - } - if this.snap.get() { - delta.x = delta.x.round(); - delta.y = delta.y.round(); - } - let mut editor = this.editor.borrow_mut(); - this.snap_guides.set((None, None)); - this.layout_grids.borrow_mut().clear_snap(); - if this.snap.get() { - if let Some(bounds) = bounds { - let proposed = Rect::new( - bounds.x + delta.x, - bounds.y + delta.y, - bounds.width, - bounds.height, - ); - // An axis-constrained drag must stay on its original axis. - let snap_x = !shift || delta.x != 0.0; - let snap_y = !shift || delta.y != 0.0; - let direction = shift.then_some(if snap_x { Point::new(1.0, 0.0) } else { Point::new(0.0, 1.0) }); - let guide = this.layout_grids.borrow_mut().snap_bounds(&editor, proposed, - 5.0 / this.viewport.get().zoom, exclude, direction); - if let Some(guide) = guide { - delta = delta + guide.delta; - } else { - let snap = editor.smart_snap(proposed, 5.0 / this.viewport.get().zoom, exclude); - if snap_x { delta.x += snap.delta.x; } - if snap_y { delta.y += snap.delta.y; } - this.snap_guides.set(( - if snap_x { snap.vertical_guide } else { None }, - if snap_y { snap.horizontal_guide } else { None }, - )); - } - } - } - for original in nodes.iter() { - if editor.document.effective_locked(original.id) { continue; } - let parent = original - .parent - .and_then(|id| editor.document.world_transform(id)) - .unwrap_or(core::Transform::IDENTITY); - let local = parent - .inverse() - .unwrap_or(core::Transform::IDENTITY) - .apply_vector(delta); - if let Some(node) = editor.document.nodes.get_mut(&original.id) { - node.x = original.x + local.x; - node.y = original.y + local.y; - } - } - editor.refresh_preview_nodes(exclude); - if let Some(probe) = this.free_layout_probe.borrow_mut().as_mut() { - probe.record_revision(editor.revision); - } - *moved = true; - } - Gesture::Resize { node, corner } => { - let mut editor = this.editor.borrow_mut(); - this.layout_grids.borrow_mut().clear_snap(); - if editor.document.effective_locked(node.id) { return; } - let parent = node - .parent - .and_then(|id| editor.document.world_transform(id)) - .unwrap_or(core::Transform::IDENTITY); - let transform = parent * node.local_transform(); - let local = transform - .inverse() - .unwrap_or(core::Transform::IDENTITY) - .apply(world); - let opposite = match *corner { - 0 => Point::new(node.width, node.height), - 1 => Point::new(0.0, node.height), - 2 => Point::new(0.0, 0.0), - _ => Point::new(node.width, 0.0), - }; - let mut width = (local.x - opposite.x).abs().max(1.0); - let mut height = (local.y - opposite.y).abs().max(1.0); - if shift { - let ratio = node.width / node.height.max(0.001); - if width / height > ratio { - height = width / ratio - } else { - width = height * ratio - } - } - if this.snap.get() { - width = width.round().max(1.0); - height = height.round().max(1.0); - // Project the final handle after pixel/aspect calculations. - // A later rounding pass must not undo the guide alignment. - let sign = match *corner { - 0 => Point::new(-1.0, -1.0), 1 => Point::new(1.0, -1.0), - 2 => Point::new(1.0, 1.0), _ => Point::new(-1.0, 1.0), - }; - let handle = transform.apply(opposite + Point::new(sign.x * width, sign.y * height)); - let direction = shift.then_some(transform.apply_vector(Point::new(sign.x * width, sign.y * height))); - let guide = this.layout_grids.borrow_mut().snap(&editor, &[handle], - 5.0 / this.viewport.get().zoom, &[node.id], direction); - if let (Some(guide), Some(inverse)) = (guide, transform.inverse()) { - let local = inverse.apply(handle + guide.delta); - let adjusted_width = sign.x * (local.x - opposite.x); - let adjusted_height = sign.y * (local.y - opposite.y); - if adjusted_width >= 1.0 && adjusted_height >= 1.0 { - width = adjusted_width; height = adjusted_height; - } else { this.layout_grids.borrow_mut().clear_snap(); } - } - } - let new_opposite = match *corner { - 0 => Point::new(width, height), - 1 => Point::new(0.0, height), - 2 => Point::new(0.0, 0.0), - _ => Point::new(width, 0.0), - }; - let mut updated = node.clone(); - updated.x = 0.0; - updated.y = 0.0; - updated.width = width; - updated.height = height; - let anchor_in_parent = node.local_transform().apply(opposite); - let offset = updated.local_transform().apply(new_opposite); - editor.resize_node_and_position( - node.id, - width, - height, - Point::new(anchor_in_parent.x - offset.x, anchor_in_parent.y - offset.y), - ); - } - Gesture::Rotate { - node, - center, - angle, - } => { - let mut rotation = node.rotation - + (world.y - center.y).atan2(world.x - center.x).to_degrees() - - *angle; - if shift { - rotation = (rotation / 15.0).round() * 15.0 - } - let mut editor = this.editor.borrow_mut(); - if let Some(n) = editor.document.nodes.get_mut(&node.id) { - n.rotation = rotation; - } - editor.refresh_preview_nodes(&[node.id]); - } - Gesture::Marquee { current, .. } => *current = world, - Gesture::Draw { start, id } => { - let mut end = this.snap_point(world); - if shift { - let size = (end.x - start.x).abs().max((end.y - start.y).abs()); - end = Point::new( - start.x + size * (end.x - start.x).signum(), - start.y + size * (end.y - start.y).signum(), - ); - } - this.layout_grids.borrow_mut().clear_snap(); - if this.snap.get() { - let editor = this.editor.borrow(); - let direction = shift.then_some(end - *start); - if let Some(snap) = this.layout_grids.borrow_mut().snap(&editor, &[end], - 5.0 / this.viewport.get().zoom, &[*id], direction) { end = end + snap.delta; } - } - let bounds = Rect::from_points(*start, end); - let mut editor = this.editor.borrow_mut(); - if let Some(n) = editor.document.nodes.get_mut(id) { - n.x = bounds.x; - n.y = bounds.y; - n.width = bounds.width.max(1.0); - n.height = bounds.height.max(1.0); - if n.kind == NodeKind::Line { - n.flip_y = (end.x - start.x) * (end.y - start.y) < 0.0; - } - } - editor.refresh(); - } + Gesture::Pan { .. } => return, // Handled before tool hit testing. + move_drag @ Gesture::Move { .. } => this.update_free_move(move_drag, world, shift), + Gesture::Scale(drag) => drag.update(&mut this.editor.borrow_mut(), world, e.key_modifiers().contains(KeyModifiers::ALT)), + Gesture::Resize(drag) => drag.update(this, world, e.key_modifiers()), + Gesture::MultiResize(drag) => drag.update(this, world, e.key_modifiers()), + Gesture::Rotate(drag) => drag.update(this, world, e.key_modifiers()), + Gesture::RotationOrigin(drag) => drag.update(this, world, e.key_modifiers()), + Gesture::AltDrag(drag) => drag.update(this, world, e.key_modifiers()), + Gesture::Marquee(drag) => { drag.update(this, &this.editor.borrow(), world, e.key_modifiers()); } + Gesture::Draw(drag) => drag.update(this, world, e.key_modifiers(), false), + Gesture::LineEndpoint(drag) => drag.update(this, world, e.key_modifiers()), Gesture::Pencil { id, origin } => { let local = world - *origin; let mut editor = this.editor.borrow_mut(); @@ -1261,9 +1277,9 @@ impl InputElementImpl for DesignCanvas { n.path.segments.push(PathSegment::LineTo { point: local }); n.width = n.width.max(local.x); n.height = n.height.max(local.y); + editor.refresh_preview_nodes(&[*id]); } } - editor.refresh(); } Gesture::PenHandle { id, @@ -1309,7 +1325,7 @@ impl InputElementImpl for DesignCanvas { } this.pen_tangent.set(Some(*anchor + delta)); } - editor.refresh(); + editor.refresh_preview_nodes(&[*id]); } Gesture::NetworkPoint { node, point, start, original } => { let mut delta = world - *start; @@ -1348,10 +1364,10 @@ impl InputElementImpl for DesignCanvas { } } drop(gesture); - this.invalidate_visual(); - if camera_changed { - this.emit(CanvasEvent::Camera); + if resize_revision.is_some_and(|revision| revision != this.editor.borrow().revision) { + this.emit(CanvasEvent::ResizePreview); } + this.invalidate_visual(); } fn on_pointer_released(this: &Self, e: &PointerReleasedEventArgs) { Self::parent_on_pointer_released(this, e); @@ -1370,8 +1386,18 @@ impl InputElementImpl for DesignCanvas { e.set_handled(true); return; } + let screen = e.get_position(Some(this)); + if this.release_ruler(Point::new(screen.x, screen.y), e.pointer().id(), e.key_modifiers()) { + e.pointer().capture(None); e.set_handled(true); return; + } + if this.pan_pointer(Point::new(screen.x, screen.y)) { + *this.gesture.borrow_mut() = Gesture::None; + e.pointer().capture(None); + e.set_handled(true); + return; + } if this.linear_gradient_active() { - this.linear_gradient_move(this.world(e.get_position(Some(this)))); + this.linear_gradient_move(this.world(screen)); } if this.finish_linear_gradient_edit() { e.pointer().capture(None); @@ -1414,21 +1440,49 @@ impl InputElementImpl for DesignCanvas { e.set_handled(true); return; } + let mut gesture = std::mem::replace(&mut *this.gesture.borrow_mut(), Gesture::None); + this.activate_edge_drag(&mut gesture, Point::new(release_screen.x, release_screen.y)); + // Hosts may coalesce the last move into the release event. + match &mut gesture { + move_drag @ Gesture::Move { .. } => this.update_free_move( + move_drag, this.world(release_screen), e.key_modifiers().contains(KeyModifiers::SHIFT), + ), + Gesture::Scale(drag) => drag.update(&mut this.editor.borrow_mut(), this.world(release_screen), e.key_modifiers().contains(KeyModifiers::ALT)), + Gesture::Draw(drag) => drag.update(this, this.world(release_screen), e.key_modifiers(), true), + Gesture::LineEndpoint(drag) => drag.update(this, this.world(release_screen), e.key_modifiers()), + Gesture::Resize(drag) => drag.update(this, this.world(release_screen), e.key_modifiers()), + Gesture::Rotate(drag) => drag.update(this, this.world(release_screen), e.key_modifiers()), + Gesture::RotationOrigin(drag) => drag.update(this, this.world(release_screen), e.key_modifiers()), + Gesture::MultiResize(drag) => drag.update(this, this.world(release_screen), e.key_modifiers()), + _ => {} + } this.snap_guides.set((None, None)); this.layout_grids.borrow_mut().clear_snap(); - let gesture = std::mem::replace(&mut *this.gesture.borrow_mut(), Gesture::None); // Clear the gesture before releasing capture: capture-lost is synchronous. e.pointer().capture(None); match gesture { - Gesture::Marquee { - start, - current, - additive, - } => { - this.editor - .borrow_mut() - .select_rect(Rect::from_points(start, current), additive); - this.emit(CanvasEvent::Selection); + Gesture::EdgePress { world, modifiers, clicks, .. } => { + this.set_resize_hover(None); + if !this.cycle_pointer_click(world, modifiers) { + this.select_at_pointer(world, modifiers, clicks, None); + } + } + Gesture::Rotate(drag) => drag.finish(this), + Gesture::MultiResize(drag) => drag.finish(this), + Gesture::Resize(drag) => drag.finish(this), + Gesture::AltDrag(drag) => drag.finish(this, this.world(release_screen), e.key_modifiers()), + Gesture::Marquee(mut drag) => { + let mut editor = this.editor.borrow_mut(); + if drag.update(this, &editor, this.world(release_screen), e.key_modifiers()) + && editor.selection != drag.preview { + editor.selection = drag.preview; + drop(editor); + this.emit(CanvasEvent::Selection); + } + } + Gesture::Scale(drag) => { + let changed = drag.commit(&mut this.editor.borrow_mut()); + this.emit(if changed { CanvasEvent::Document } else { CanvasEvent::Selection }); } Gesture::Move { moved: false, .. } => { this.editor.borrow_mut().cancel_transaction(); @@ -1436,21 +1490,27 @@ impl InputElementImpl for DesignCanvas { Gesture::Pencil { id, .. } => { let mut editor = this.editor.borrow_mut(); normalize_drawn_vector(&mut editor, id); - editor.commit_transaction(); + this.commit_created_layer(&mut editor, id); drop(editor); this.tool.set(Tool::Select); this.emit(CanvasEvent::Document); } + Gesture::Draw(drag) => { + this.commit_created_layer(&mut this.editor.borrow_mut(), drag.id); + this.tool.set(Tool::Select); + this.emit(CanvasEvent::Document); + if drag.tool == Tool::Text { + this.emit(CanvasEvent::EditText(drag.id)); + } + } Gesture::Move { .. } - | Gesture::Resize { .. } - | Gesture::Rotate { .. } + | Gesture::LineEndpoint(_) | Gesture::VectorPoint { .. } | Gesture::VectorBend { .. } | Gesture::NetworkPoint { .. } - | Gesture::NetworkBend { .. } - | Gesture::Draw { .. } => { + | Gesture::NetworkBend { .. } => { this.editor.borrow_mut().commit_transaction(); - this.tool.set(Tool::Select); + if this.tool.get() != Tool::Scale { this.tool.set(Tool::Select); } this.emit(CanvasEvent::Document); } _ => {} @@ -1459,6 +1519,13 @@ impl InputElementImpl for DesignCanvas { } fn on_pointer_capture_lost(this: &Self, e: &PointerCaptureLostEventArgs) { Self::parent_on_pointer_capture_lost(this, e); + if this.lost_ruler_capture(e.pointer().id()) { return; } + if matches!(*this.gesture.borrow(), Gesture::Pan { .. }) { + // A temporary hand drag can coexist with an unfinished pen path. + // Losing camera capture must never roll back that transaction. + *this.gesture.borrow_mut() = Gesture::None; + return; + } if this.cancel_linear_gradient_gesture() { return; } if this.cancel_corner_radius_gesture() { return; } if this.cancel_stroke_width_gesture() { return; } @@ -1476,6 +1543,8 @@ impl InputElementImpl for DesignCanvas { } fn on_pointer_exited(this: &Self, e: &PointerEventArgs) { Self::parent_on_pointer_exited(this, e); + this.clear_measurement(); + this.set_resize_hover(None); this.clear_gradient_hover(); this.clear_corner_radius_hover(); this.clear_stroke_width_hover(); @@ -1485,6 +1554,8 @@ impl InputElementImpl for DesignCanvas { if let Some(id) = this.hover.take() { this.emit(CanvasEvent::PrototypeTrigger(id, core::Trigger::MouseLeave)); } + } else if this.hover.take().is_some() { + this.invalidate_visual(); } } fn on_pointer_wheel_changed(this: &Self, e: &PointerWheelEventArgs) { @@ -1514,9 +1585,17 @@ impl InputElementImpl for DesignCanvas { } else { v.pan = v.pan + Point::new(e.delta().x * 36.0, e.delta().y * 36.0); } - this.viewport.set(v); - this.invalidate_visual(); - this.emit(CanvasEvent::Camera); + this.update_camera(v); + } + fn on_lost_focus(this: &Self, e: &FocusChangedEventArgs) { + Self::parent_on_lost_focus(this, e); + this.cancel_guide_drag(); + this.clear_measurement(); + if matches!(*this.gesture.borrow(), Gesture::Marquee(_) | Gesture::AltDrag(_) | Gesture::Resize(_) | Gesture::MultiResize(_) | Gesture::Rotate(_) | Gesture::RotationOrigin(_)) { + this.cancel(); + } + // KeyUp is routed to the new focus owner. + this.space.set(false); } fn on_key_down(this: &Self, e: &KeyEventArgs) { Self::parent_on_key_down(this, e); @@ -1529,6 +1608,19 @@ impl InputElementImpl for DesignCanvas { e.set_handled(true); return; } + if this.ruler_alt(e.key, true) || this.ruler_key(e) { e.set_handled(true); return; } + if this.rotation_key(e) { e.set_handled(true); return; } + if this.rotation_modifier(e, true) || this.resize_modifier(e, true) || this.marquee_modifier(e, true) || this.alt_drag_modifier(e, true) { + e.set_handled(true); + return; + } + if matches!(e.key, Key::LeftAlt | Key::RightAlt) && this.measure_at_pointer(e.key_modifiers | KeyModifiers::ALT) { + e.set_handled(true); + return; + } + if matches!(e.key, Key::LeftCtrl | Key::RightCtrl | Key::LWin | Key::RWin) && e.key_modifiers.contains(KeyModifiers::ALT) { + this.measure_at_pointer(e.key_modifiers); + } if this.linear_gradient_key(e.key, e.key_modifiers) { e.set_handled(true); return; } if e.key == Key::Escape && this.cancel_corner_radius_gesture() { e.set_handled(true); @@ -1541,6 +1633,13 @@ impl InputElementImpl for DesignCanvas { return; } if e.key == Key::Space { + this.clear_measurement(); + if this.space.replace(true) { + e.set_handled(true); + return; + } + this.set_resize_hover(None); + if this.hover.take().is_some() { this.invalidate_visual(); } this.cancel_linear_gradient_gesture(); this.clear_gradient_hover(); this.cancel_corner_radius_gesture(); @@ -1549,7 +1648,6 @@ impl InputElementImpl for DesignCanvas { this.clear_stroke_width_hover(); this.cancel_network_bucket_gesture(); this.clear_bucket_hover(); - this.space.set(true); e.set_handled(true); return; } @@ -1583,7 +1681,6 @@ impl InputElementImpl for DesignCanvas { .key_modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::META); let shift = e.key_modifiers.contains(KeyModifiers::SHIFT); - let alt = e.key_modifiers.contains(KeyModifiers::ALT); if matches!(e.key, Key::Left | Key::Right | Key::Up | Key::Down | Key::Enter | Key::Tab | Key::Delete | Key::Back) { this.finish_corner_radius_edit(); this.finish_network_bucket(); @@ -1607,61 +1704,14 @@ impl InputElementImpl for DesignCanvas { e.set_handled(true); return; } - let action = if command { - match e.key { - Key::Z => Some(if shift { "redo" } else { "undo" }), - Key::Y => Some("redo"), - Key::C => Some("copy"), - Key::X => Some("cut"), - Key::V => Some("paste"), - Key::D => Some("duplicate"), - Key::A => Some(if shift { "select-inverse" } else { "select-all" }), - Key::G => Some(if alt { - "frame-selection" - } else if shift { - "ungroup" - } else { - "group" - }), - Key::S => Some("save"), - Key::O => Some("open"), - Key::K => Some(if shift { - "place-image" - } else if alt { - "component" - } else { - "actions" - }), - _ => None, - } - } else { - match e.key { - Key::Delete | Key::Back => Some("delete"), - Key::V => Some("select"), - Key::F => Some(if shift { "section" } else { "frame" }), - Key::R => Some("rectangle"), - Key::O => Some("ellipse"), - Key::L => Some("line"), - Key::P => Some(if shift { "pencil" } else { "pen" }), - Key::B if shift && this.vector_node.get().is_some() => Some("network-bucket"), - Key::T => Some("text"), - Key::H => Some("hand"), - Key::C => Some("comment"), - Key::A if shift => Some("auto-layout"), - Key::S if shift => Some("section"), - Key::D1 => Some("fit"), - Key::D2 => Some("fit-selection"), - Key::K => Some("actions"), - _ => None, - } - }; + let action = this.editor_shortcut(e); if let Some(action) = action { if !matches!(action, "undo" | "redo") { this.cancel_layout_drag(); } this.emit(CanvasEvent::Action(action)); e.set_handled(true); return; } - let step = if shift { 10.0 } else { 1.0 }; + let step = this.nudge.get().step(shift); let delta = match e.key { Key::Left => Some((-step, 0.0)), Key::Right => Some((step, 0.0)), @@ -1671,7 +1721,15 @@ impl InputElementImpl for DesignCanvas { }; if let Some((x, y)) = delta { this.cancel_layout_drag(); - if let (Some(id), Some(point)) = (this.vector_node.get(), this.vector_point.get()) { + if let Some(id) = this.vector_node.get() { + if let Some(point) = this.network_point.get() { + let changed = this.editor.borrow_mut().nudge_network_points(id, point, + &this.network_vertices.borrow(), Point::new(x, y)); + if changed { this.emit(CanvasEvent::Document); this.invalidate_visual(); } + e.set_handled(true); + return; + } + let Some(point) = this.vector_point.get() else { e.set_handled(true); return; }; let mut editor = this.editor.borrow_mut(); let world = editor .document @@ -1683,8 +1741,9 @@ impl InputElementImpl for DesignCanvas { if let Some(world) = world { editor.move_vector_point(id, point, world + Point::new(x, y)); } - } else { - this.editor.borrow_mut().move_selection(x, y); + } else if !this.editor.borrow_mut().nudge_selection(Point::new(x, y)) { + e.set_handled(true); + return; } this.emit(CanvasEvent::Document); this.invalidate_visual(); @@ -1693,6 +1752,14 @@ impl InputElementImpl for DesignCanvas { } fn on_key_up(this: &Self, e: &KeyEventArgs) { Self::parent_on_key_up(this, e); + if this.ruler_alt(e.key, false) || this.rotation_modifier(e, false) || this.resize_modifier(e, false) || this.marquee_modifier(e, false) || this.alt_drag_modifier(e, false) { + e.set_handled(true); + return; + } + if matches!(e.key, Key::LeftAlt | Key::RightAlt) { this.clear_measurement(); } + if matches!(e.key, Key::LeftCtrl | Key::RightCtrl | Key::LWin | Key::RWin) && e.key_modifiers.contains(KeyModifiers::ALT) { + this.measure_at_pointer(e.key_modifiers); + } if e.key == Key::Space { this.space.set(false); e.set_handled(true); @@ -1803,6 +1870,20 @@ fn label(ctx: &mut DrawingContext, text: &str, point: Point, size: f64, color: & ); ctx.draw_text(&text, ui(point)); } +/// Captions stay at a readable screen font size while the artboard zooms. +fn canvas_caption(ctx: &mut DrawingContext, name: &str, origin: Point, screen_width: f64, color: &str) { + // The root-origin query includes 400 DIPs to the left of the viewport. + let width = (screen_width - 4.0).min(400.0); + if width < 20.0 || name.is_empty() { return; } + let caption = FormattedText::new( + name, CultureInfo::invariant_culture(), FlowDirection::LeftToRight, + Typeface::from_name("fonts:Inter#Inter"), 11.0, Some(brush(color)), + ); + caption.set_max_text_width(width); + caption.set_max_line_count(1); + caption.set_trimming(::character_ellipsis()); + ctx.draw_text(&caption, ui(origin)); +} fn render_vector_editor( ctx: &mut DrawingContext, path: &VectorPath, @@ -1897,32 +1978,33 @@ impl VisualImpl for DesignCanvas { fn render(this: &Self, ctx: &mut DrawingContext) { let bounds = this.bounds(); let mut v = this.viewport.get(); + let resized = v.width != bounds.width || v.height != bounds.height; v.width = bounds.width; v.height = bounds.height; - if v.width <= 0.0 || v.height <= 0.0 { + // The browser surface reports 1×1 until its first real resize. A + // placeholder frame must not consume the pending document fit. + if v.width <= 1.0 || v.height <= 1.0 { return; } let editor = this.editor.borrow(); let prototype_scene = this.prototype_scene.borrow(); let doc = prototype_scene.as_ref().map(|scene| &scene.document).unwrap_or(&editor.document); - let roots: Vec = if let Some(scene) = prototype_scene.as_ref() { - vec![scene.root] - } else if let Some(root) = this.presentation.get() { - vec![root] - } else { - doc.page(editor.active_page) - .map(|p| p.children.clone()) - .unwrap_or_default() + let presentation_root = prototype_scene.as_ref().map(|scene| scene.root).or(this.presentation.get()); + let roots = match &presentation_root { + Some(root) => std::slice::from_ref(root), + None => doc.page(editor.active_page).map_or(&[][..], |page| page.children.as_slice()), }; - let fitted = prototype_scene.is_none() && !this.initialized.replace(true); + let fitted = prototype_scene.is_none() && (!this.initialized.replace(true) || (resized && presentation_root.is_some())); if fitted { if let Some(bounds) = roots .iter() .filter_map(|&id| doc.world_bounds(id)) .reduce(|a, b| a.union(b)) { - v.fit(bounds, 65.0); - if this.presentation.get().is_none() { + if presentation_root.is_some() { + crate::prototype::fit_preview_frame(&mut v, bounds); + } else { + v.fit(bounds, 65.0); v.pan.y -= 22.0; } } @@ -1951,6 +2033,8 @@ impl VisualImpl for DesignCanvas { "#181818" } else if this.dark.get() { "#252525" + } else if this.show_outlines.get() && prototype_scene.is_none() { + "#FCFCFC" } else { &page_hex }); @@ -1992,14 +2076,23 @@ impl VisualImpl for DesignCanvas { let mut painter = this.painter.borrow_mut(); painter.set_raster_scale(v.zoom * display_scale); if prototype_scene.is_none() && this.presentation.get().is_none() { - painter.render_editor(ctx, &editor, &roots, v.visible_rect()); + if this.show_outlines.get() { + this.outlines.borrow_mut().render(ctx, &editor, v.visible_rect(), v.zoom, + this.outline_hidden.get(), this.outline_bounds.get(), this.dark.get(), &[]); + } else { + painter.render_editor(ctx, &editor, roots, v.visible_rect()); + } + if let Gesture::AltDrag(drag) = &*this.gesture.borrow() { + drag.render(ctx, this, &editor, v.visible_rect(), v.zoom * display_scale); + } } else { - painter.render(ctx, doc, &roots, v.visible_rect()); + painter.render(ctx, doc, roots, v.visible_rect()); } } ctx.pop(camera); if this.presentation.get().is_none() && prototype_scene.is_none() { this.layout_grids.borrow_mut().render(ctx, &editor, v); + this.render_ruler_guides(ctx, &editor, v); } if let Some(current) = this.presentation.get() { for &(id, position) in this.overlays.borrow().iter().filter(|_| prototype_scene.is_none()) { @@ -2037,13 +2130,7 @@ impl VisualImpl for DesignCanvas { ctx.pop(transform); } } - label( - ctx, - "Esc to return to editor", - Point::new(20.0, v.height - 30.0), - 11.0, - "#AAAAAA", - ); + return; } let blue = pen(0xFF0D99FF, 1.0); @@ -2059,50 +2146,72 @@ impl VisualImpl for DesignCanvas { ctx.draw_line(&guide, UiPoint::new(0.0, y), UiPoint::new(v.width, y)); } } - for &id in &roots { - if let Some(n) = doc.nodes.get(&id) { - if n.kind.is_container() && n.visible { - if let Some(bounds) = doc.world_bounds(id) { - let p = v.world_to_screen(Point::new(bounds.x, bounds.y)); - if p.x > -400.0 && p.x < v.width && p.y > -30.0 && p.y < v.height { - label( - ctx, - &n.name, - Point::new(p.x, p.y - 20.0), - 11.0, - if matches!(n.kind, NodeKind::Component | NodeKind::Instance) { - "#9747FF" - } else if this.dark.get() { - "#999999" - } else { - "#777777" - }, - ); + let captions = Rect::from_points( + v.screen_to_world(Point::new(-400.0, -30.0)), + v.screen_to_world(Point::new(v.width, v.height)), + ); + slices::draw(ctx, &editor, v); + for (id, origin) in editor.scene.root_container_origins_in(captions) { + let node = &doc.nodes[&id]; + let point = v.world_to_screen(origin); + let color = if matches!(node.kind, NodeKind::Component | NodeKind::Instance) { + "#9747FF" + } else if this.dark.get() { "#999999" } else { "#777777" }; + // Reuse the world transform prepared by the spatial index, including shear/rotation. + let transform = editor.scene.transforms[&id]; + let local = node.local_bounds(); + let world_width = transform.a.abs() * local.width + transform.c.abs() * local.height; + canvas_caption(ctx, &node.name, Point::new(point.x, point.y - 20.0), world_width * v.zoom, color); + } + let measuring = this.measurement_target(); + if let Some(id) = measuring.or_else(|| this.highlighted_layer()) { + let outline = if measuring.is_some() { pen(measure::GUIDE_COLOR, 1.0) } else { blue.clone() }; + if !editor.selection.contains(&id) && doc.effective_visible(id) { + if let (Some(node), Some(transform)) = (doc.nodes.get(&id), editor.scene.transforms.get(&id)) { + let points = node.local_bounds().corners() + .map(|p| ui(v.world_to_screen(transform.apply(p)))); + if node.kind == NodeKind::Line { + ctx.draw_line(&outline, points[0], points[2]); + } else { + for i in 0..4 { + ctx.draw_line(&outline, points[i], points[(i + 1) % 4]); } } } } } - if let Some(id) = this.hover.get() { - if !editor.selection.contains(&id) { - if let Some(b) = doc.world_bounds(id) { - let a = v.world_to_screen(Point::new(b.x, b.y)); - ctx.draw_rectangle_outline( - &blue, - UiRect::new(a.x, a.y, b.width * v.zoom, b.height * v.zoom), - 0.0, - ); - } - } - } let white = brush("#FFFFFF"); - for &id in &editor.selection { + let scale_mode = this.tool.get() == Tool::Scale; + let gesture = this.gesture.borrow(); + let marquee = match &*gesture { + Gesture::Marquee(drag) => Some(drag), + _ => None, + }; + if marquee.is_none() && (scale_mode || (this.tool.get() == Tool::Select && editor.selection.len() > 1 + && matches!(*gesture, Gesture::None | Gesture::MultiResize(_) | Gesture::RotationOrigin(_)))) { + this.render_scale_handles(ctx, &editor, v); + } + let selection = match &*gesture { + Gesture::Marquee(drag) => drag.preview.as_slice(), + Gesture::AltDrag(drag) if drag.is_previewing(&editor) => &[], + _ => editor.selection.as_slice(), + }; + for &id in selection { let Some(n) = doc.nodes.get(&id) else { continue; }; - let Some(t) = doc.world_transform(id) else { + let Some(&t) = editor.scene.transforms.get(&id) else { continue; }; + if marquee.is_some() { + let points = n.local_bounds().corners().map(|p| ui(v.world_to_screen(t.apply(p)))); + if n.kind == NodeKind::Line { + ctx.draw_line(&blue, points[0], points[2]); + } else { + for i in 0..4 { ctx.draw_line(&blue, points[i], points[(i + 1) % 4]); } + } + continue; + } if this.render_linear_gradient(ctx, &editor, id) { continue; } if this.render_image_crop(ctx, n, t, v) { continue; } if this.render_stroke_width(ctx, &editor, id, t, v) { continue; } @@ -2133,10 +2242,29 @@ impl VisualImpl for DesignCanvas { .local_bounds() .corners() .map(|p| v.world_to_screen(t.apply(p))); + if scale_mode { + for i in 0..4 { ctx.draw_line(&blue, ui(corners[i]), ui(corners[(i + 1) % 4])); } + continue; + } + let show_grips = editor.selection.len() == 1 && this.tool.get() == Tool::Select + && !this.space.get() && !doc.effective_locked(id) && doc.effective_visible(id) + && t.inverse().is_some(); + if n.kind == NodeKind::Line { + ctx.draw_line(&blue, ui(corners[0]), ui(corners[2])); + if show_grips { + for point in [corners[0], corners[2]] { + ctx.draw_ellipse_at(Some(&white), Some(&blue), ui(point), 3.5, 3.5); + } + } + continue; + } for i in 0..4 { ctx.draw_line(&blue, ui(corners[i]), ui(corners[(i + 1) % 4])); } - if editor.selection.len() == 1 { + if show_grips { + if let Some((_, edge)) = this.hovered_resize_edge.get().filter(|(target, _)| *target == id) { + ctx.draw_line(&pen(0xFF0D99FF, 2.0), ui(corners[edge]), ui(corners[(edge + 1) % 4])); + } for p in corners { ctx.draw_rectangle( Some(&white), @@ -2147,40 +2275,18 @@ impl VisualImpl for DesignCanvas { &BoxShadows::default(), ); } - let top = (corners[0] + corners[1]) * 0.5; - let center = (corners[0] + corners[2]) * 0.5; - let direction = top - center; - let length = direction.distance(Point::new(0.0, 0.0)).max(0.001); - let rotate = top + direction * (24.0 / length); - ctx.draw_line(&blue, ui(top), ui(rotate)); - ctx.draw_ellipse_at(Some(&white), Some(&blue), ui(rotate), 3.0, 3.0); - if let Some(b) = doc.world_bounds(id) { - let p = v.world_to_screen(Point::new(b.x + b.width / 2.0, b.bottom())); - let value = format!("{} × {}", n.width.round(), n.height.round()); - let width = value.len() as f64 * 6.2 + 16.0; - let fill = brush("#0D99FF"); - ctx.draw_rectangle( - Some(&fill), - None, - UiRect::new(p.x - width / 2.0, p.y + 9.0, width, 22.0), - 4.0, - 4.0, - &BoxShadows::default(), - ); - label( - ctx, - &value, - Point::new(p.x - width / 2.0 + 8.0, p.y + 13.0), - 10.0, - "#FFFFFF", - ); - } + } + if editor.selection.len() == 1 { + let bounds = Rect::from_points(corners[0], corners[2]) + .union(Rect::from_points(corners[1], corners[3])); + if measuring.is_none() && !matches!(*gesture, Gesture::Rotate(_)) { this.draw_size_badge(ctx, resize::BadgeValue::Dimensions(n.width, n.height), bounds, v); } this.render_corner_radius(ctx, &editor, id); } } - if let Gesture::Marquee { start, current, .. } = &*this.gesture.borrow() { - let a = v.world_to_screen(*start); - let b = v.world_to_screen(*current); + this.render_rotation(ctx, &editor, v, &gesture); + if let Some(drag) = marquee { + let a = v.world_to_screen(drag.start); + let b = v.world_to_screen(drag.current); let fill = brush("#200D99FF"); ctx.draw_rectangle( Some(&fill), @@ -2191,7 +2297,9 @@ impl VisualImpl for DesignCanvas { &BoxShadows::default(), ); } + if let Gesture::AltDrag(drag) = &*gesture { drag.draw_guides(ctx, &editor, v); } this.render_layout_drag(ctx, v); + this.draw_measurements(ctx, &editor, v); for comment in &doc.comments { if !comment.resolved && doc.comment_page(comment) == Some(editor.active_page) { let p = v.world_to_screen(comment.position); @@ -2200,31 +2308,6 @@ impl VisualImpl for DesignCanvas { label(ctx, "C", Point::new(p.x - 3.5, p.y - 6.0), 10.0, "#2A2200"); } } - if this.show_rulers.get() { - let fill = brush("#F5F5F5"); - ctx.draw_rectangle( - Some(&fill), - None, - UiRect::new(0.0, 0.0, v.width, 20.0), - 0.0, - 0.0, - &BoxShadows::default(), - ); - let step = 100.0 * if v.zoom < 0.3 { 5.0 } else { 1.0 }; - let mut x = (v.visible_rect().x / step).floor() * step; - let tick = pen(0xFFBBBBBB, 1.0); - while x < v.visible_rect().right() { - let px = v.world_to_screen(Point::new(x, 0.0)).x; - ctx.draw_line(&tick, UiPoint::new(px, 14.0), UiPoint::new(px, 20.0)); - label( - ctx, - &format!("{x:.0}"), - Point::new(px + 3.0, 3.0), - 9.0, - "#777777", - ); - x += step; - } - } + this.render_rulers(ctx, &editor, v); } } diff --git a/crates/ferrodesign-app/src/canvas/drag_copy.rs b/crates/ferrodesign-app/src/canvas/drag_copy.rs new file mode 100644 index 0000000..181ea07 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/drag_copy.rs @@ -0,0 +1,269 @@ +//! Option-click cycles layers. Crossing four screen DIPs changes that click +//! into a drag whose artwork is transient until the native pointer releases. +use super::*; +use ferroui_base::input::StandardCursorType; + +pub(super) struct AltDrag { + start: Point, + start_screen: Point, + current: Point, + press_modifiers: KeyModifiers, + hit: Option, + original: Vec, + page: NodeId, + token: core::EditorRenderToken, + active: bool, + copy: bool, + session: Option, + roots: Vec, + bounds: Option, + target: Option, + painter: RefCell, +} + +impl DesignCanvas { + pub(super) fn press_alt_drag(&self, world: Point, screen: Point, modifiers: KeyModifiers) -> bool { + if self.tool.get() != Tool::Select + || self.vector_node.get().is_some() + || !modifiers.contains(KeyModifiers::ALT) + || modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) + { + return false; + } + let hit = self.pick_with_modifiers(world, modifiers, false); + self.begin_alt_drag(hit, world, screen, modifiers); + true + } + + fn begin_alt_drag(&self, hit: Option, world: Point, screen: Point, modifiers: KeyModifiers) { + let editor = self.editor.borrow(); + if editor.has_active_transaction() { + return; + } + *self.gesture.borrow_mut() = Gesture::AltDrag(Box::new(AltDrag { + start: world, + start_screen: screen, + current: world, + press_modifiers: modifiers, + hit, + original: editor.selection.clone(), + page: editor.active_page, + token: editor.render_change_token(), + active: false, + copy: true, + session: None, + roots: Vec::new(), + bounds: None, + target: None, + painter: RefCell::default(), + })); + } + + pub(super) fn alt_drag_modifier(&self, event: &KeyEventArgs, pressed: bool) -> bool { + let flag = match event.key { + Key::LeftAlt | Key::RightAlt => KeyModifiers::ALT, + Key::LeftShift | Key::RightShift => KeyModifiers::SHIFT, + _ => return false, + }; + let mut modifiers = event.key_modifiers; + modifiers.set(flag, pressed); + let mut world = None; + if pressed + && flag == KeyModifiers::ALT + && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) + { + if let Some((id, origin, screen)) = self.drag_copy_origin() { + self.cancel_layout_drag(); + self.begin_alt_drag(Some(id), origin, screen, modifiers); + world = self + .selection_pointer + .get() + .map(|p| self.viewport.get().screen_to_world(p)); + } + } + let mut gesture = self.gesture.borrow_mut(); + let Gesture::AltDrag(drag) = &mut *gesture else { + return false; + }; + drag.update(self, world.unwrap_or(drag.current), modifiers); + drop(gesture); + self.invalidate_visual(); + true + } +} + +impl AltDrag { + fn valid(&self, editor: &Editor) -> bool { + editor.active_page == self.page + && editor.selection == self.original + && editor.render_change_token() == self.token + && !editor.has_active_transaction() + } + + pub(super) fn update(&mut self, canvas: &DesignCanvas, world: Point, modifiers: KeyModifiers) { + self.current = world; + let editor = canvas.editor.borrow(); + self.target = None; + if !self.valid(&editor) { + return; + } + let copy = modifiers.contains(KeyModifiers::ALT); + if !self.active || self.copy != copy { + if !self.active + && canvas + .viewport + .get() + .world_to_screen(world) + .distance(self.start_screen) + < 4.0 + { + return; + } + self.active = true; + self.copy = copy; + let Some(hit) = self.hit else { return }; + let selected = self + .original + .iter() + .copied() + .find(|id| *id == hit || editor.document.is_ancestor(*id, hit)); + let source = selected.map_or(std::slice::from_ref(&hit), |_| self.original.as_slice()); + let prepare = if copy { + core::LayoutTransferSession::for_copy + } else { + core::LayoutTransferSession::new + }; + self.session = prepare(&editor.document, source, selected.unwrap_or(hit), self.start).ok(); + self.roots = self + .session + .iter() + .flat_map(|session| session.source_ids()) + .collect(); + self.bounds = self + .roots + .iter() + .filter_map(|id| editor.document.world_bounds(*id)) + .reduce(Rect::union); + } + let Some(session) = &self.session else { return }; + let mut delta = world - self.start; + if modifiers.contains(KeyModifiers::SHIFT) { + if delta.x.abs() > delta.y.abs() { + delta.y = 0.0; + } else { + delta.x = 0.0; + } + } + if canvas.snap.get() { + delta = Point::new(delta.x.round(), delta.y.round()); + } + let Some(mut target) = session.target(self.start + delta) else { + return; + }; + canvas.snap_guides.set((None, None)); + canvas.layout_grids.borrow_mut().clear_snap(); + if canvas.snap.get() && target.kind != core::LayoutTransferKind::Flow { + if let Some(bounds) = self.bounds { + // A copy may align with its originals; both move modes exclude the + // dragged subtree's own layout guides. + let exclude = if copy { &[][..] } else { self.roots.as_slice() }; + delta = canvas.snap_move_delta( + &editor, + bounds, + delta, + modifiers.contains(KeyModifiers::SHIFT), + &self.roots, + exclude, + ); + target.position = self.start + delta; + } + } + self.target = Some(target); + let cursor = if copy { + StandardCursorType::DragCopy + } else { + StandardCursorType::DragMove + }; + canvas.set_handle_feedback(Some(cursor), None); + } + + pub(super) fn render(&self, ctx: &mut DrawingContext, canvas: &DesignCanvas, editor: &Editor, visible: Rect, scale: f64) { + let Some(target) = self.target.filter(|_| self.copy && self.valid(editor)) else { + return; + }; + let delta = target.position - self.start; + let transform = ctx.push_transform(Matrix::create_translation(delta.x, delta.y)); + let translated = Rect::new(visible.x - delta.x, visible.y - delta.y, visible.width, visible.height); + if canvas.show_outlines.get() { + canvas.outlines.borrow_mut().render(ctx, editor, translated, canvas.viewport.get().zoom, + canvas.outline_hidden.get(), canvas.outline_bounds.get(), canvas.dark.get(), &self.roots); + } else { + let mut painter = self.painter.borrow_mut(); + painter.set_raster_scale(scale); + painter.render_editor(ctx, editor, &self.roots, translated); + } + ctx.pop(transform); + } + + pub(super) fn is_previewing(&self, editor: &Editor) -> bool { + self.target.is_some() && self.valid(editor) + } + + pub(super) fn draw_guides(&self, ctx: &mut DrawingContext, editor: &Editor, view: Viewport) { + let Some(target) = self.target.filter(|_| self.valid(editor)) else { + return; + }; + let Some(session) = &self.session else { return }; + let delta = target.position - self.start; + let outline = pen(0xFF0D99FF, 1.0); + for corners in session.source_corners() { + let points = corners.map(|p| ui(view.world_to_screen(p + delta))); + for side in 0..4 { + ctx.draw_line(&outline, points[side], points[(side + 1) % 4]); + } + } + if target.kind == core::LayoutTransferKind::Flow { + let start = ui(view.world_to_screen(target.marker.start)); + let end = ui(view.world_to_screen(target.marker.end)); + ctx.draw_line(&pen(0xFF0D99FF, 3.0), start, end); + } + } + + pub(super) fn finish(mut self, canvas: &DesignCanvas, world: Point, modifiers: KeyModifiers) { + self.update(canvas, world, modifiers); + canvas.snap_guides.set((None, None)); + canvas.layout_grids.borrow_mut().clear_snap(); + canvas.set_resize_hover(None); + if !self.valid(&canvas.editor.borrow()) { + return; + } + if !self.active { + canvas.cycle_pointer_click(self.start, self.press_modifiers); + return; + } + let (Some(session), Some(target)) = (&self.session, self.target) else { + return; + }; + let mut editor = canvas.editor.borrow_mut(); + let result = if self.copy { + editor.commit_drag_copy(session, target).map(|_| ()) + } else { + editor.selection = session.selection().to_vec(); + if session.preserves_drag_parents(&editor.document, target) && !session.has_layout_source() { + let delta = target.position - self.start; + editor.move_selection(delta.x, delta.y); + Ok(()) + } else { + editor.commit_layout_transfer(session, target).map(|_| ()) + } + }; + if result.is_err() { + editor.selection = self.original; + } + drop(editor); + match result { + Ok(()) => canvas.emit(CanvasEvent::Document), + Err(error) => canvas.emit(CanvasEvent::Notice(error.to_string())), + } + } +} diff --git a/crates/ferrodesign-app/src/canvas/drawing.rs b/crates/ferrodesign-app/src/canvas/drawing.rs new file mode 100644 index 0000000..d27d45a --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/drawing.rs @@ -0,0 +1,337 @@ +//! Placement is calculated from the original press throughout the gesture. +//! Modifier changes and coalesced release events therefore use the same geometry. +use super::*; + +pub(super) struct ShapeDrag { + pub(super) id: NodeId, + pub(super) tool: Tool, + press: Point, + anchor: Point, + previous: Point, + dragging: bool, +} + +impl DesignCanvas { + pub(super) fn drawing_parent_at(&self, press: Point, tool: Tool) -> Option { + if tool == Tool::Slice { return None; } + self.containing_parent_at(press, |editor, id| { + editor.document.is_layer_destination(id) + && (tool != Tool::Section || editor.document.nodes[&id].kind == NodeKind::Section) + }) + } + + pub(super) fn containing_parent_at( + &self, press: Point, accepts: impl Fn(&Editor, NodeId) -> bool, + ) -> Option { + let editor = self.editor.borrow(); + let doc = &editor.document; + let roots = &doc.page(editor.active_page)?.children; + let mut painter = self.painter.borrow_mut(); + let mut current = editor.hit_test_filtered(press, 0.0, |id| { + painter.mask_contains(&editor, roots, id, press) + }); + // Follow the frontmost visible layer. A page-level shape over a frame + // must not silently put new artwork behind that shape. + for _ in 0..core::MAX_DOCUMENT_DEPTH { + let id = current?; + let node = doc.nodes.get(&id)?; + if accepts(&editor, id) + && doc + .world_transform(id) + .and_then(core::Transform::inverse) + .is_some_and(|inverse| node.contains_clip_point(inverse.apply(press))) + { + return Some(id); + } + current = node.parent; + } + None + } + + pub(super) fn commit_created_layer(&self, editor: &mut Editor, id: NodeId) { + // Keep the in-progress path and drag guides in world space. Adopt the + // finished node before committing, so one undo removes the whole edit. + if let Some((_, parent)) = self + .creation_parent + .take() + .filter(|(created, _)| *created == id) + { + let doc = &mut editor.document; + let inverse = doc + .world_transform(parent) + .and_then(core::Transform::inverse); + if let (Some(inverse), Some(world)) = (inverse, doc.world_transform(id)) { + if doc.is_layer_destination(parent) && doc.reparent(id, parent, usize::MAX).is_ok() + { + doc.nodes + .get_mut(&id) + .unwrap() + .set_local_transform(inverse * world); + if doc.nodes[&parent].layout.is_some() { + doc.layout_all(); + } + } + } + } + editor.commit_transaction(); + } +} + +impl ShapeDrag { + pub(super) fn new(id: NodeId, tool: Tool, press: Point, anchor: Point) -> Self { + Self { + id, + tool, + press, + anchor, + previous: press, + dragging: false, + } + } + + pub(super) fn update( + &mut self, + canvas: &DesignCanvas, + pointer: Point, + modifiers: KeyModifiers, + released: bool, + ) { + let zoom = canvas.viewport.get().zoom; + let centered = modifiers.contains(KeyModifiers::ALT); + let line = self.tool.kind() == NodeKind::Line; + if self.dragging && canvas.space.get() { + self.anchor = self.anchor + (pointer - self.previous); + } + self.previous = pointer; + self.dragging |= self.press.distance(pointer) * zoom >= 3.0; + let (start, end) = if self.dragging { + let mut delta = canvas.snap_point(pointer) - self.anchor; + let constrained = modifiers.contains(KeyModifiers::SHIFT) && self.tool != Tool::Text; + if constrained { + delta = constrained_delta(delta, line); + } + let mut end = self.anchor + delta; + canvas.layout_grids.borrow_mut().clear_snap(); + if canvas.snap.get() { + if let Some(snap) = canvas.layout_grids.borrow_mut().snap( + &canvas.editor.borrow(), + &[end], + 5.0 / zoom, + &[self.id], + constrained.then_some(delta), + ) { + end = end + snap.delta; + } + } + let start = if centered { + self.anchor - (end - self.anchor) + } else { + self.anchor + }; + (start, end) + } else if released { + let size = match self.tool { + Tool::Line | Tool::Arrow => Point::new(100.0, 0.0), + Tool::Section => Point::new(400.0, 300.0), + Tool::Text => Point::new(160.0, 36.0), + _ => Point::new(100.0, 100.0), + }; + let start = if centered { + self.anchor - size * 0.5 + } else { + self.anchor + }; + (start, start + size) + } else { + return; + }; + + let bounds = Rect::from_points(start, end); + let min_size = if line { 0.0 } else { 1.0 }; + let mut editor = canvas.editor.borrow_mut(); + let Some(node) = editor.document.nodes.get_mut(&self.id) else { + return; + }; + node.x = bounds.x; + node.y = bounds.y; + node.width = bounds.width.max(min_size); + node.height = bounds.height.max(min_size); + if line { + // Keep the end marker at pointer-up for all four drag directions. + node.flip_x = end.x < start.x; + node.flip_y = end.y < start.y; + } + if self.tool == Tool::Text { + node.text_auto_resize = if self.dragging { + core::TextAutoResize::Fixed + } else { + core::TextAutoResize::WidthAndHeight + }; + } + editor.refresh_preview_nodes(&[self.id]); + } +} + +pub(super) fn constrained_delta(delta: Point, line: bool) -> Point { + if line { + // Choose the nearest of the eight compass directions. Project onto + // that ray so horizontal/vertical lines have an exactly zero extent. + let threshold = std::f64::consts::SQRT_2 - 1.0; + if delta.y.abs() <= delta.x.abs() * threshold { + return Point::new(delta.x, 0.0); + } + if delta.x.abs() <= delta.y.abs() * threshold { + return Point::new(0.0, delta.y); + } + let extent = (delta.x.abs() + delta.y.abs()) * 0.5; + return Point::new(extent.copysign(delta.x), extent.copysign(delta.y)); + } + let extent = delta.x.abs().max(delta.y.abs()); + Point::new(extent.copysign(delta.x), extent.copysign(delta.y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn canvas(tool: Tool, press: Point) -> (Ref, ShapeDrag) { + let document = core::Document::new("Shape placement"); + let canvas = DesignCanvas::new(Rc::new(RefCell::new(Editor::new(document)))); + canvas.snap.set(false); + let id = canvas.new_node(tool, press).unwrap(); + (canvas, ShapeDrag::new(id, tool, press, press)) + } + + #[test] + fn click_tolerates_screen_jitter_and_release_can_be_the_first_drag_sample() { + let start = Point::new(10.0, 20.0); + let (canvas, mut drag) = canvas(Tool::Ellipse, start); + canvas.viewport.set(Viewport { + zoom: 2.0, + ..canvas.viewport.get() + }); + drag.update(&canvas, Point::new(11.0, 20.0), KeyModifiers::empty(), true); + let mut editor = canvas.editor.borrow_mut(); + assert_eq!(editor.document.nodes[&drag.id].width, 100.0); + assert_eq!(editor.document.nodes[&drag.id].height, 100.0); + assert!(editor.commit_transaction()); + assert!(editor.undo()); + assert!(editor.document.nodes.is_empty()); + assert!(editor.redo()); + assert_eq!(editor.document.nodes[&drag.id].x, start.x); + } + + #[test] + fn shape_modifiers_are_live_and_invalidate_only_the_created_layer() { + let center = Point::new(100.0, 50.0); + let (canvas, mut drag) = canvas(Tool::Rectangle, center); + let token = canvas.editor.borrow().render_change_token(); + let pointer = Point::new(80.0, 53.0); + drag.update( + &canvas, + pointer, + KeyModifiers::SHIFT | KeyModifiers::ALT, + false, + ); + { + let editor = canvas.editor.borrow(); + let node = &editor.document.nodes[&drag.id]; + assert_eq!( + (node.x, node.y, node.width, node.height), + (80.0, 30.0, 40.0, 40.0) + ); + assert_eq!( + editor.render_changes_since(&token), + core::EditorRenderChanges::Nodes { + nodes: vec![drag.id], + ancestors: vec![], + } + ); + } + drag.update(&canvas, pointer, KeyModifiers::empty(), true); + let editor = canvas.editor.borrow(); + let node = &editor.document.nodes[&drag.id]; + assert_eq!( + (node.x, node.y, node.width, node.height), + (80.0, 50.0, 20.0, 3.0) + ); + } + + #[test] + fn constrained_arrow_preserves_its_directed_endpoints_in_every_quadrant() { + let start = Point::new(30.0, 30.0); + for (delta, expected) in [ + ((29.0, -5.0), (29.0, 0.0)), + ((-3.0, -23.0), (0.0, -23.0)), + ((-9.0, 11.0), (-10.0, 10.0)), + ((-11.0, -9.0), (-10.0, -10.0)), + ] { + let (canvas, mut drag) = canvas(Tool::Arrow, start); + let pointer = start + Point::new(delta.0, delta.1); + drag.update(&canvas, pointer, KeyModifiers::SHIFT, true); + let editor = canvas.editor.borrow(); + let node = &editor.document.nodes[&drag.id]; + let transform = node.local_transform(); + assert_eq!(transform.apply(Point::default()), start); + assert_eq!( + transform.apply(Point::new(node.width, node.height)), + start + Point::new(expected.0, expected.1), + ); + assert_eq!(node.strokes[0].cap, core::StrokeCap::Arrow); + } + } + + #[test] + fn creating_in_a_rotated_frame_keeps_world_geometry_and_rolls_back_membership() { + use ferroui_base::{platform::IPlatformRenderInterface, FerroLocator}; + let scope = FerroLocator::enter_scope(); + FerroLocator::current_mutable() + .bind::() + .to_constant(Rc::new(ferroui_vello::PlatformRenderInterface::default())); + let mut document = core::Document::new("Nested drawing"); + let page = document.pages[0].id; + let mut frame = Node::new(NodeKind::Frame, "Rotated frame", 30.0, 40.0, 120.0, 120.0); + frame.rotation = 31.0; + let parent = document.insert_node(page, frame).unwrap(); + let canvas = DesignCanvas::new(Rc::new(RefCell::new(Editor::new(document)))); + canvas.snap.set(false); + let start = Point::new(90.0, 100.0); + let end = Point::new(56.0, 117.0); + let id = canvas.new_node(Tool::Arrow, start).unwrap(); + let mut drag = ShapeDrag::new(id, Tool::Arrow, start, start); + drag.update(&canvas, end, KeyModifiers::empty(), true); + let mut editor = canvas.editor.borrow_mut(); + canvas.commit_created_layer(&mut editor, id); + assert_eq!(editor.document.nodes[&id].parent, Some(parent)); + let world = editor.document.world_transform(id).unwrap(); + assert!(world.apply(Point::default()).distance(start) < 1e-8); + assert!(world.apply(Point::new(34.0, 17.0)).distance(end) < 1e-8); + assert_eq!(editor.history_len(), 1); + assert!(editor.undo()); + assert!(editor.document.nodes[&parent].children.is_empty()); + assert!(editor.redo()); + assert_eq!(editor.document.nodes[&parent].children, [id]); + scope.dispose(); + } + + #[test] + fn text_click_and_drag_choose_the_native_text_sizing_modes() { + let start = Point::new(20.0, 30.0); + for (end, expected_mode) in [ + (start, core::TextAutoResize::WidthAndHeight), + (Point::new(8.0, 64.0), core::TextAutoResize::Fixed), + ] { + let (canvas, mut drag) = canvas(Tool::Text, start); + drag.update(&canvas, end, KeyModifiers::empty(), true); + let editor = canvas.editor.borrow(); + let node = &editor.document.nodes[&drag.id]; + assert_eq!(node.text_auto_resize, expected_mode); + if start != end { + assert_eq!( + (node.x, node.y, node.width, node.height), + (8.0, 30.0, 12.0, 34.0) + ); + } + } + } +} diff --git a/crates/ferrodesign-app/src/canvas/gradient_edit.rs b/crates/ferrodesign-app/src/canvas/gradient_edit.rs index fdb32bd..d7a5eff 100644 --- a/crates/ferrodesign-app/src/canvas/gradient_edit.rs +++ b/crates/ferrodesign-app/src/canvas/gradient_edit.rs @@ -1032,12 +1032,10 @@ impl DesignCanvas { value.stops[index].offset = (value.stops[index].offset + sign * step).clamp(0.0, 1.0); } else { - let step = if modifiers.contains(KeyModifiers::SHIFT) { - 10.0 - } else if modifiers.contains(KeyModifiers::ALT) { + let step = if modifiers.contains(KeyModifiers::ALT) && !modifiers.contains(KeyModifiers::SHIFT) { 0.1 } else { - 1.0 + self.nudge.get().step(modifiers.contains(KeyModifiers::SHIFT)) }; let world_delta = if matches!(key, Key::Left | Key::Right) { Point::new(sign * step, 0.0) diff --git a/crates/ferrodesign-app/src/canvas/grids.rs b/crates/ferrodesign-app/src/canvas/grids.rs index 0c8891b..cbff4c9 100644 --- a/crates/ferrodesign-app/src/canvas/grids.rs +++ b/crates/ferrodesign-app/src/canvas/grids.rs @@ -14,6 +14,7 @@ const MAX_FRAME_LINES: usize = 32_768; #[derive(Default)] pub(super) struct LayoutGridOverlay { + pub(super) rulers_visible: bool, grids: BTreeSet, token: Option, snap_index: snapping::GuideSnapIndex, diff --git a/crates/ferrodesign-app/src/canvas/grids/snapping.rs b/crates/ferrodesign-app/src/canvas/grids/snapping.rs index 6771a10..c4a07ee 100644 --- a/crates/ferrodesign-app/src/canvas/grids/snapping.rs +++ b/crates/ferrodesign-app/src/canvas/grids/snapping.rs @@ -155,6 +155,13 @@ impl LayoutGridOverlay { // it remains deterministic in the constant-work geometry helper. if preferred.contains(&id) { break; } } + if self.rulers_visible { + if let Some(ruler) = core::snap_ruler_guides(editor, points, threshold, exclude, direction) { + if self.active_snap.is_none_or(|grid| ruler.delta.x.hypot(ruler.delta.y) <= grid.delta.x.hypot(grid.delta.y)) { + self.active_snap = Some(ruler); + } + } + } self.active_snap } diff --git a/crates/ferrodesign-app/src/canvas/layout_drag.rs b/crates/ferrodesign-app/src/canvas/layout_drag.rs index eae4a5e..4976ee1 100644 --- a/crates/ferrodesign-app/src/canvas/layout_drag.rs +++ b/crates/ferrodesign-app/src/canvas/layout_drag.rs @@ -167,6 +167,15 @@ impl DesignCanvas { } } + pub(super) fn drag_copy_origin(&self) -> Option<(NodeId, Point, Point)> { + let editor = self.editor.borrow(); + if let Some(probe) = self.free_layout_probe.borrow().as_ref().filter(|probe| probe.owns(&editor)) { + return Some((probe.session.child(), probe.start_world, probe.start_screen)); + } + self.layout_drag.borrow().as_ref().filter(|drag| drag.owns(&editor)) + .map(|drag| (drag.session.child(), drag.start_world, drag.start_screen)) + } + pub fn cancel_layout_drag(&self) -> bool { let active = self.layout_drag.borrow_mut().take().is_some(); let probe = self.free_layout_probe.borrow_mut().take(); @@ -190,6 +199,82 @@ impl DesignCanvas { active || had_probe } + /// Use the same absolute, snapped position for move samples and pointer-up. + /// A host may deliver the final motion only on release. + pub(super) fn update_free_move(&self, gesture: &mut Gesture, world: Point, shift: bool) { + let Gesture::Move { start, nodes, bounds, exclude, moved } = gesture else { return; }; + let mut delta = world - *start; + if delta.distance(Point::new(0.0, 0.0)) < 0.5 / self.viewport.get().zoom && !*moved + { + return; + } + if shift { + if delta.x.abs() > delta.y.abs() { + delta.y = 0.0 + } else { + delta.x = 0.0 + } + } + if self.snap.get() { + delta.x = delta.x.round(); + delta.y = delta.y.round(); + } + let mut editor = self.editor.borrow_mut(); + self.snap_guides.set((None, None)); + self.layout_grids.borrow_mut().clear_snap(); + if let Some(bounds) = bounds.filter(|_| self.snap.get()) { + delta = self.snap_move_delta(&editor, bounds, delta, shift, exclude, exclude); + } + let mut changed = false; + for original in nodes.iter() { + if editor.document.effective_locked(original.id) { continue; } + let parent = original + .parent + .and_then(|id| editor.document.world_transform(id)) + .unwrap_or(core::Transform::IDENTITY); + let local = parent + .inverse() + .unwrap_or(core::Transform::IDENTITY) + .apply_vector(delta); + if let Some(node) = editor.document.nodes.get_mut(&original.id) { + let position = Point::new(original.x + local.x, original.y + local.y); + if node.x != position.x || node.y != position.y { + node.x = position.x; + node.y = position.y; + changed = true; + } + } + } + if !changed { + return; + } + editor.refresh_preview_nodes(exclude); + if let Some(probe) = self.free_layout_probe.borrow_mut().as_mut() { + probe.record_revision(editor.revision); + } + *moved = true; + } + + pub(super) fn snap_move_delta( + &self, editor: &Editor, bounds: Rect, mut delta: Point, shift: bool, + grid_exclude: &[NodeId], object_exclude: &[NodeId], + ) -> Point { + let proposed = Rect::new(bounds.x + delta.x, bounds.y + delta.y, bounds.width, bounds.height); + let tolerance = 5.0 / self.viewport.get().zoom; + let snap_x = !shift || delta.x != 0.0; + let snap_y = !shift || delta.y != 0.0; + let direction = shift.then_some(if snap_x { Point::new(1.0, 0.0) } else { Point::new(0.0, 1.0) }); + if let Some(guide) = self.layout_grids.borrow_mut().snap_bounds(editor, proposed, tolerance, grid_exclude, direction) { + delta = delta + guide.delta; + } else { + let snap = editor.smart_snap(proposed, tolerance, object_exclude); + if snap_x { delta.x += snap.delta.x; } + if snap_y { delta.y += snap.delta.y; } + self.snap_guides.set((snap.vertical_guide.filter(|_| snap_x), snap.horizontal_guide.filter(|_| snap_y))); + } + delta + } + pub(super) fn layout_drag_move(&self, world: Point, screen: Point) -> bool { self.layout_drag_move_constrained(world, screen, false) } diff --git a/crates/ferrodesign-app/src/canvas/line_endpoint.rs b/crates/ferrodesign-app/src/canvas/line_endpoint.rs new file mode 100644 index 0000000..7a82192 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/line_endpoint.rs @@ -0,0 +1,164 @@ +//! Direct line editing keeps stroke, arrow-cap and affine paint coordinates in +//! the layer's original local basis while moving its geometric endpoints. +use super::*; + +pub(super) struct EndpointDrag { + source: Node, + inverse: core::Transform, + start: Point, + end: Point, + press: Point, + move_start: bool, +} + +impl EndpointDrag { + pub(super) fn new( + editor: &Editor, + source: Node, + move_start: bool, + press: Point, + ) -> Option { + let world = editor.document.world_transform(source.id)?; + let end = world.apply(Point::new(source.width, source.height)); + Some(Self { + source, + inverse: world.inverse()?, + start: world.apply(Point::default()), + end, + press, + move_start, + }) + } + + pub(super) fn update(&self, canvas: &DesignCanvas, pointer: Point, modifiers: KeyModifiers) { + let centered = modifiers.contains(KeyModifiers::ALT); + let (moving, fixed) = if self.move_start { + (self.start, self.end) + } else { + (self.end, self.start) + }; + let anchor = if centered { + (moving + fixed) * 0.5 + } else { + fixed + }; + let delta = pointer - self.press; + let mut editor = canvas.editor.borrow_mut(); + if editor.document.effective_locked(self.source.id) { + return; + } + if delta.distance(Point::default()) < 0.01 / canvas.viewport.get().zoom { + // Preserve the source's exact reflection/affine decomposition when + // clicking a handle or returning it to its original position. + let Some(node) = editor.document.nodes.get_mut(&self.source.id) else { + return; + }; + if *node == self.source { + return; + } + *node = self.source.clone(); + } else { + let mut target = canvas.snap_point(moving + delta); + let constrained = modifiers.contains(KeyModifiers::SHIFT); + if constrained { + target = anchor + drawing::constrained_delta(target - anchor, true); + } + canvas.layout_grids.borrow_mut().clear_snap(); + if canvas.snap.get() { + if let Some(snap) = canvas.layout_grids.borrow_mut().snap( + &editor, + &[target], + 5.0 / canvas.viewport.get().zoom, + &[self.source.id], + constrained.then_some(target - anchor), + ) { + target = target + snap.delta; + } + } + let opposite = if centered { + anchor - (target - anchor) + } else { + fixed + }; + let (start, end) = if self.move_start { + (target, opposite) + } else { + (opposite, target) + }; + let start = self.inverse.apply(start); + let vector = self.inverse.apply(end) - start; + let Some(node) = editor.document.nodes.get_mut(&self.source.id) else { + return; + }; + node.width = vector.x.abs(); + node.height = vector.y.abs(); + node.set_local_transform( + self.source.local_transform() + * core::Transform::translation(start.x, start.y) + * core::Transform::scale( + 1.0_f64.copysign(vector.x), + 1.0_f64.copysign(vector.y), + ), + ); + } + editor.refresh_preview_nodes(&[self.source.id]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directed_line_keeps_the_opposite_endpoint_through_crossing_and_center_resize() { + let mut document = core::Document::new("Endpoint manipulation"); + let mut line = Node::new(NodeKind::Line, "Arrow", 10.0, 20.0, 70.0, 0.0); + line.strokes[0].cap = core::StrokeCap::Arrow; + let id = document.insert_node(document.pages[0].id, line).unwrap(); + let canvas = DesignCanvas::new(Rc::new(RefCell::new(Editor::new(document)))); + canvas.snap.set(false); + canvas.editor.borrow_mut().selection = vec![id]; + let press = Point::new(80.0, 21.0); + assert_eq!(canvas.handle_at(press).unwrap().handle, resize::Handle::Corner(2)); + let source = canvas.editor.borrow().document.nodes[&id].clone(); + let drag = EndpointDrag::new(&canvas.editor.borrow(), source, false, press).unwrap(); + canvas + .editor + .borrow_mut() + .begin_node_transaction(&[id], "Move line endpoint"); + for (mods, pointer, expected_start, expected_end) in [ + ( + KeyModifiers::SHIFT, + (-20.0, 22.0), + (10.0, 20.0), + (-20.0, 20.0), + ), + ( + KeyModifiers::ALT | KeyModifiers::SHIFT, + (47.0, 1.0), + (45.0, 40.0), + (45.0, 0.0), + ), + ] { + drag.update(&canvas, Point::new(pointer.0, pointer.1), mods); + let editor = canvas.editor.borrow(); + let node = &editor.document.nodes[&id]; + let world = node.local_transform(); + assert!( + world + .apply(Point::default()) + .distance(Point::new(expected_start.0, expected_start.1)) + < 1e-8 + ); + assert!( + world + .apply(Point::new(node.width, node.height)) + .distance(Point::new(expected_end.0, expected_end.1)) + < 1e-8 + ); + assert_eq!(node.strokes[0].cap, core::StrokeCap::Arrow); + } + drag.update(&canvas, press, KeyModifiers::empty()); + assert!(!canvas.editor.borrow_mut().commit_transaction()); + } +} diff --git a/crates/ferrodesign-app/src/canvas/measure.rs b/crates/ferrodesign-app/src/canvas/measure.rs new file mode 100644 index 0000000..cef8416 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/measure.rs @@ -0,0 +1,248 @@ +//! Transient distances between layer bounds. Geometry is in document units; +//! the overlay, end caps and text are drawn in screen DIPs. +use super::*; + +pub(super) const GUIDE_COLOR: u32 = 0xFFE43C60; + +#[derive(Default)] +pub(super) struct Measurements { + target: Cell>, + labels: RefCell<[Option<(f64, FormattedText)>; 4]>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct Span { + from: Point, + to: Point, + along_x: bool, +} + +fn horizontal(x1: f64, x2: f64, y: f64) -> Span { + Span { from: Point::new(x1, y), to: Point::new(x2, y), along_x: true } +} +fn vertical(y1: f64, y2: f64, x: f64) -> Span { + Span { from: Point::new(x, y1), to: Point::new(x, y2), along_x: false } +} + +// When projections are disjoint the nearest facing edges define the gap. +// If they intersect, use the midpoint of the shared projection as the ruler. +fn gap(a: f64, b: f64, c: f64, d: f64) -> Option<(f64, f64)> { + if b <= c { + Some((b, c)) + } else if d <= a { + Some((a, d)) + } else { + None + } +} + +fn spans(a: Rect, b: Rect) -> [Option; 4] { + let x = (a.x.max(b.x) + a.right().min(b.right())) * 0.5; + let y = (a.y.max(b.y) + a.bottom().min(b.bottom())) * 0.5; + match (gap(a.x, a.right(), b.x, b.right()), gap(a.y, a.bottom(), b.y, b.bottom())) { + (Some((ax, bx)), Some((ay, by))) => [Some(horizontal(ax, bx, ay)), Some(vertical(ay, by, bx)), None, None], + (Some((ax, bx)), None) => [Some(horizontal(ax, bx, y)), None, None, None], + (None, Some((ay, by))) => [Some(vertical(ay, by, x)), None, None, None], + // Nested and overlapping bounds expose their corresponding edge offsets. + (None, None) => [ + horizontal(a.x, b.x, y), + horizontal(a.right(), b.right(), y), + vertical(a.y, b.y, x), + vertical(a.bottom(), b.bottom(), x), + ] + .map(|span| (span.from != span.to).then_some(span)), + } +} + +impl DesignCanvas { + pub(crate) fn measurement_target(&self) -> Option { + self.measurements.target.get() + } + + pub(super) fn clear_measurement(&self) { + if self.measurements.target.take().is_some() { + self.invalidate_visual(); + } + } + + /// Consumes only idle hover. Pointer presses still go through the normal + /// cycle-selection, centered-resize and tool handlers. + pub(super) fn measure_hover(&self, world: Point, modifiers: KeyModifiers) -> bool { + if !modifiers.contains(KeyModifiers::ALT) + || self.tool.get() != Tool::Select + || self.space.get() + || self.guide_drag_active() + || self.presentation.get().is_some() + || !self.is_focused() + || self.vector_node.get().is_some() + || self.linear_gradient_active() + || self.image_crop.get().is_some() + || self.stroke_width_mode_active() + || self.editor.borrow().has_active_transaction() + || self.layout_drag.borrow().is_some() + || self.editor.borrow().selection.is_empty() + || !matches!(*self.gesture.borrow(), Gesture::None) + { + self.clear_measurement(); + return false; + } + if self.hover_edit_handle(world, modifiers) { + return true; + } + let hit = self.pick_with_modifiers(world, modifiers, false); + let editor = self.editor.borrow(); + let target = hit.and_then(|id| { + if !editor.selection.contains(&id) { + return Some(id); + } + let &[selected] = editor.selection.as_slice() else { + return None; + }; + editor + .document + .nodes + .get(&selected)? + .parent + .filter(|parent| editor.document.nodes.contains_key(parent)) + }); + drop(editor); + let previous_hit = self.hover.replace(hit); + if self.measurements.target.replace(target) != target || previous_hit != hit { + self.invalidate_visual(); + } + true + } + + pub(super) fn measure_at_pointer(&self, modifiers: KeyModifiers) -> bool { + self.selection_pointer + .get() + .filter(|_| self.is_pointer_over()) + .is_some_and(|screen| self.measure_hover(self.viewport.get().screen_to_world(screen), modifiers)) + } + + pub(super) fn draw_measurements(&self, ctx: &mut DrawingContext, editor: &Editor, v: Viewport) { + let lines = if let Some(core::RulerRedlines { origin: from, edges }) = self.ruler_redlines(editor, v) { + let [a, b] = edges.map(|end| end.map(|(to, distance)| { + let along_x = (to.x - from.x).abs() >= (to.y - from.y).abs(); + (Span { from, to, along_x }, distance) + })); + [a, b, None, None] + } else { + let Some(target) = self.measurement_target() else { return; }; + let bounds = |id: NodeId| { + let transform = editor.scene.transforms.get(&id)?; + Some(transform.transform_rect(editor.document.nodes.get(&id)?.local_bounds())) + }; + let Some(b) = bounds(target) else { return; }; + let Some(a) = editor.selection.iter().filter_map(|id| bounds(*id)).reduce(Rect::union) else { return; }; + spans(a, b).map(|line| line.map(|span| (span, span.from.distance(span.to)))) + }; + let guide = pen(GUIDE_COLOR, 1.0); + let ink = brush("#BC244B"); + let view = Rect::new(0.0, 0.0, v.width, v.height); + let mut labels = self.measurements.labels.borrow_mut(); + let mut bodies = [None; 4]; + + for (i, line) in lines.into_iter().enumerate() { + let Some((span, distance)) = line else { continue; }; + let from = v.world_to_screen(span.from); + let to = v.world_to_screen(span.to); + let Some(visible) = Rect::from_points(from, to).intersection(view) else { continue; }; + let direction = to - from; + let length = from.distance(to); + let cap = if length > 1e-9 { + Point::new(-direction.y, direction.x) * (3.0 / length) + } else if span.along_x { + Point::new(0., 3.) + } else { + Point::new(3., 0.) + }; + ctx.draw_line(&guide, ui(from), ui(to)); + for end in [from, to] { + ctx.draw_line(&guide, ui(end - cap), ui(end + cap)); + } + if labels[i].as_ref().is_none_or(|(old, _)| *old != distance) { + let precise = format!("{distance:.2}"); + let text = if distance > 0.0 && distance < 0.005 { + "<0.01" + } else { + precise.trim_end_matches('0').trim_end_matches('.') + }; + labels[i] = Some(( + distance, + FormattedText::new( + text, + CultureInfo::invariant_culture(), + FlowDirection::LeftToRight, + Typeface::from_name("fonts:Inter#Inter"), + 11.0, + Some(brush("#FFFFFF")), + ), + )); + } + let label = &labels[i].as_ref().unwrap().1; + let (w, h) = (label.width().ceil() + 10.0, label.height().ceil() + 4.0); + if w + 4.0 > v.width || h + 4.0 > v.height { + continue; + } + let center = visible.center(); + let body = |x: f64, y: f64| { + Rect::new(x.clamp(2.0, v.width - w - 2.0), y.clamp(2.0, v.height - h - 2.0), w, h) + }; + let candidates = if span.along_x { + [body(center.x - w / 2.0, center.y - h - 4.0), body(center.x - w / 2.0, center.y + 4.0)] + } else { + [body(center.x + 4.0, center.y - h / 2.0), body(center.x - w - 4.0, center.y - h / 2.0)] + }; + bodies[i] = candidates + .into_iter() + .find(|rect| !bodies[..i].iter().flatten().any(|old: &Rect| old.intersects(*rect))); + } + for (entry, body) in labels.iter().zip(bodies) { + if let (Some((_, label)), Some(body)) = (entry, body) { + ctx.draw_rectangle(Some(&ink), None, rect(body), 4.0, 4.0, &BoxShadows::default()); + ctx.draw_text(label, ui(Point::new(body.x + 5.0, body.y + 2.0))); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gaps_use_only_separated_axes_and_reverse_symmetrically() { + let selected = Rect::new(-40.0, 10.0, 120.0, 50.0); + let target = Rect::new(95.125, 20.0, 30.0, 10.0); + assert_eq!(spans(selected, target), [Some(horizontal(80.0, 95.125, 25.0)), None, None, None]); + assert_eq!(spans(target, selected), [Some(horizontal(95.125, 80.0, 25.0)), None, None, None]); + let flush = Rect::new(80.0, 20.0, 30.0, 10.0); + assert_eq!(spans(selected, flush), [Some(horizontal(80.0, 80.0, 25.0)), None, None, None]); + let diagonal = Rect::new(95.125, -20.0, 30.0, 12.5); + assert_eq!( + spans(selected, diagonal), + [Some(horizontal(80.0, 95.125, 10.0)), Some(vertical(10.0, -7.5, 95.125)), None, None] + ); + assert_eq!( + spans(selected, Rect::new(20.0, 80.0, 30.0, 40.0)), + [Some(vertical(60.0, 80.0, 35.0)), None, None, None] + ); + } + + #[test] + fn parent_insets_use_authored_world_bounds_and_coincident_edges_have_no_length() { + let parent = Rect::new(20.0, 25.0, 250.0, 130.0); + let child = Rect::new(53.5, 50.0, 60.0, 30.0); + assert_eq!( + spans(child, parent), + [ + Some(horizontal(53.5, 20.0, 65.0)), + Some(horizontal(113.5, 270.0, 65.0)), + Some(vertical(50.0, 25.0, 83.5)), + Some(vertical(80.0, 155.0, 83.5)) + ] + ); + assert_eq!(spans(child, child), [None; 4]); + } +} diff --git a/crates/ferrodesign-app/src/canvas/resize.rs b/crates/ferrodesign-app/src/canvas/resize.rs new file mode 100644 index 0000000..f0eb1f4 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/resize.rs @@ -0,0 +1,479 @@ +//! Move-tool handles operate in the layer's local axes. Hit areas stay in screen DIPs. +use super::*; +use ferroui_base::input::{Cursor, StandardCursorType}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Handle { + Corner(usize), + Edge(usize), +} + +impl Handle { + fn axes(self) -> Point { + match self { + Self::Corner(i) => { + [Point::new(-1.0, -1.0), Point::new(1.0, -1.0), Point::new(1.0, 1.0), Point::new(-1.0, 1.0)] + [i] + } + Self::Edge(i) => { + [Point::new(0.0, -1.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(-1.0, 0.0)][i] + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct Hit { + pub(super) handle: Handle, + id: NodeId, + transform: core::Transform, +} + +impl Hit { + fn cursor(self) -> StandardCursorType { + let axis = self.transform.apply_vector(self.handle.axes()); + // The platform exposes four bidirectional resize cursors. + let octant = (axis.y.atan2(axis.x) / std::f64::consts::FRAC_PI_4).round() as i32; + match octant.rem_euclid(4) { + 0 => StandardCursorType::SizeWestEast, + 1 => StandardCursorType::TopLeftCorner, + 2 => StandardCursorType::SizeNorthSouth, + _ => StandardCursorType::TopRightCorner, + } + } +} + +impl DesignCanvas { + pub(super) fn resize_with_modifiers(&self, modifiers: KeyModifiers) -> bool { + if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) { return true; } + let editor = self.editor.borrow(); + editor.selection.iter().any(|id| editor.document.nodes.get(id).is_some_and(Node::can_resize_around_contents)) + } + + // An unpainted side can cross a rounded corner, a star cutout, or another layer. + // Keep the press eligible for ordinary picking until the pointer becomes a drag. + pub(super) fn activate_edge_drag(&self, gesture: &mut Gesture, pointer: Point) { + let Gesture::EdgePress { hit, world, screen, .. } = *gesture else { return; }; + if pointer.distance(screen) < 3.0 { return; } + let mut editor = self.editor.borrow_mut(); + if editor.selection.as_slice() != [hit.id] || editor.document.effective_locked(hit.id) { return; } + if let Some(drag) = ResizeDrag::new(&mut editor, hit, world) { + *gesture = Gesture::Resize(drag); + } + } + + pub(super) fn set_resize_hover(&self, hit: Option) { + let edge = hit.and_then(|h| match h.handle { + Handle::Edge(i) => Some((h.id, i)), + _ => None, + }); + self.set_handle_feedback(hit.map(Hit::cursor), edge); + } + + pub(super) fn set_handle_feedback( + &self, + cursor: Option, + edge: Option<(NodeId, usize)>, + ) { + if self.manipulation_cursor.replace(cursor) != cursor { + self.set_cursor(cursor.map(Cursor::new)); + } + if self.hovered_resize_edge.replace(edge) != edge { + self.invalidate_visual(); + } + } + + pub(super) fn handle_at(&self, pointer: Point) -> Option { + if self.tool.get() != Tool::Select + || self.space.get() + || self.presentation.get().is_some() + || self.vector_node.get().is_some() + || self.pen_node.get().is_some() + || self.image_crop.get().is_some() + || self.stroke_width_mode_active() + { + return None; + } + let editor = self.editor.borrow(); + let &[id] = editor.selection.as_slice() else { + return None; + }; + let node = editor.document.nodes.get(&id)?; + let transform = *editor.scene.transforms.get(&id)?; + transform.inverse()?; + if editor.document.effective_locked(id) || !editor.document.effective_visible(id) { + return None; + } + let corners = node.local_bounds().corners().map(|p| transform.apply(p)); + let zoom = self.viewport.get().zoom; + let hit = |handle| Some(Hit { handle, id, transform }); + let line = node.kind == NodeKind::Line; + let candidates: &[usize] = if line { &[2, 0] } else { &[0, 1, 2, 3] }; + let nearest = candidates + .iter() + .map(|&i| (i, corners[i].distance(pointer))) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + if nearest.1 * zoom < 8.0 { + return hit(Handle::Corner(nearest.0)); + } + if line { + return None; + } + // A narrow edge target leaves the interior available for moving layers and editing radii. + (0..4) + .map(|i| (i, core::distance_to_segment(pointer, corners[i], corners[(i + 1) % 4]))) + .filter(|(_, d)| d * zoom <= 4.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .and_then(|(i, _)| hit(Handle::Edge(i))) + } +} + +pub(super) struct ResizeDrag { + session: core::SelectionResize, + id: NodeId, + original: Rect, + local: core::Transform, + world: core::Transform, + inverse: core::Transform, + press: Point, + axes: Point, +} + +impl ResizeDrag { + pub(super) fn new(editor: &mut Editor, hit: Hit, press: Point) -> Option { + let node = editor.document.nodes.get(&hit.id)?; + let original = node.bounds(); + let local = node.local_transform(); + Some(Self { + id: hit.id, + original, + local, + world: hit.transform, + inverse: hit.transform.inverse()?, + press, + axes: hit.handle.axes(), + session: editor.begin_single_resize(hit.id)?, + }) + } + + fn anchor(&self, width: f64, height: f64, centered: bool) -> Point { + let axes = if centered { Point::default() } else { self.axes }; + Point::new(width * (1.0 - axes.x) * 0.5, height * (1.0 - axes.y) * 0.5) + } + + fn constrain(&self, size: Point) -> Point { + let ratio = self.original.width.max(0.001) / self.original.height.max(0.001); + if self.axes.y == 0.0 || (self.axes.x != 0.0 && size.x > size.y * ratio) { + Point::new(size.x, size.x / ratio) + } else { + Point::new(size.y * ratio, size.y) + } + } + + pub(super) fn update(&mut self, canvas: &DesignCanvas, pointer: Point, modifiers: KeyModifiers) { + let mut editor = canvas.editor.borrow_mut(); + canvas.layout_grids.borrow_mut().clear_snap(); + if editor.document.effective_locked(self.id) { + return; + } + let delta = self.inverse.apply_vector(pointer - self.press); + let centered = modifiers.contains(KeyModifiers::ALT); + let constrained = modifiers.contains(KeyModifiers::SHIFT); + let extent = if centered { 2.0 } else { 1.0 }; + let original = Point::new(self.original.width, self.original.height); + let axes = self.axes; + let original_anchor = self.anchor(original.x, original.y, centered); + let mut size = original; + // Use motion from the original press. Grabbing either side of the stroke cannot jump, + // and a click/release preserves fractional authored sizes even with pixel snap enabled. + if (delta.x * axes.x).abs().max((delta.y * axes.y).abs()) > 1e-9 { + let snap_pixels = canvas.snap.get(); + let dimension = |start: f64, distance: f64, axis: f64| { + if axis == 0.0 { + return start; + } + let value = (start + axis * distance * extent).max(1.0); + if snap_pixels { + value.round() + } else { + value + } + }; + size = Point::new(dimension(original.x, delta.x, axes.x), dimension(original.y, delta.y, axes.y)); + if constrained { + size = self.constrain(size); + } + if snap_pixels { + let offset = Point::new(axes.x * size.x / extent, axes.y * size.y / extent); + let handle = self.world.apply(original_anchor + offset); + let direction = + (constrained || axes.x == 0.0 || axes.y == 0.0).then(|| self.world.apply_vector(offset)); + let mut grids = canvas.layout_grids.borrow_mut(); + if let Some(snap) = + grids.snap(&editor, &[handle], 5.0 / canvas.viewport.get().zoom, &[self.id], direction) + { + let local = self.inverse.apply_vector(snap.delta); + let adjusted = + Point::new(size.x + local.x * axes.x * extent, size.y + local.y * axes.y * extent); + if (axes.x == 0.0 || adjusted.x >= 1.0) && (axes.y == 0.0 || adjusted.y >= 1.0) { + size = if constrained { self.constrain(adjusted) } else { adjusted }; + } else { + grids.clear_snap(); + } + } + } + } + let Some(node) = editor.document.nodes.get(&self.id) else { + return; + }; + let position = if size == original { + Point::new(self.original.x, self.original.y) + } else { + let zero = Rect::new(0.0, 0.0, size.x, size.y); + self.local.apply(original_anchor) + - node.local_transform_for_bounds(zero).apply(self.anchor(size.x, size.y, centered)) + }; + let bounds = Rect::new(position.x, position.y, size.x, size.y); + self.session.preview_layer(&mut editor, bounds, modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META)); + } + + pub(super) fn cancel(self, canvas: &DesignCanvas) { + self.session.cancel(&mut canvas.editor.borrow_mut()); + canvas.set_resize_hover(None); + canvas.layout_grids.borrow_mut().clear_snap(); + canvas.invalidate_visual(); + canvas.emit(CanvasEvent::Selection); + } + + pub(super) fn finish(self, canvas: &DesignCanvas) { + let changed = self.session.commit(&mut canvas.editor.borrow_mut()); + canvas.set_resize_hover(None); + canvas.emit(if changed { CanvasEvent::Document } else { CanvasEvent::Selection }); + } +} + +fn dimensions(width: f64, height: f64) -> String { + let width = format!("{width:.2}"); + let height = format!("{height:.2}"); + format!( + "{} × {}", + width.trim_end_matches('0').trim_end_matches('.'), + height.trim_end_matches('0').trim_end_matches('.') + ) +} + +fn badge_rect(bounds: Rect, view: Viewport, width: f64, height: f64, obstacle: Option) -> Option { + const MARGIN: f64 = 4.0; + const GAP: f64 = 8.0; + let visible = bounds.intersection(Rect::new(0.0, 0.0, view.width, view.height))?; + if width + 2.0 * MARGIN > view.width || height + 2.0 * MARGIN > view.height { + return None; + } + let x = (visible.center().x - width * 0.5).clamp(MARGIN, view.width - width - MARGIN); + let below = bounds.bottom() + GAP; + // If the outside label would be clipped, bring it just inside the visible lower edge. + let y = if below + height + MARGIN <= view.height { + below + } else { + (visible.bottom() - GAP - height).max(MARGIN) + }; + let preferred = Rect::new(x, y, width, height); + let Some(dock) = obstacle else { return Some(preferred) }; + let dock = dock.inflate(MARGIN); + let clear = |body: &Rect| body.intersection(dock).is_none_or(|r| r.width == 0.0 || r.height == 0.0); + if clear(&preferred) { return Some(preferred); } + + // Prefer the inside of the selection's lower edge. For a selection that + // runs beneath the dock, choose the closest clear side of the actual + // overlay. The viewport margin still applies on narrow or clipped views. + let safe = Rect::new(MARGIN, MARGIN, view.width - 2.0 * MARGIN, view.height - 2.0 * MARGIN); + [ + Rect::new(x, (visible.bottom() - GAP - height).max(MARGIN), width, height), + Rect::new(x, dock.y - height, width, height), + Rect::new(x, dock.bottom(), width, height), + Rect::new(dock.x - width, y, width, height), + Rect::new(dock.right(), y, width, height), + ].into_iter().find(|body| safe.contains_rect(*body) && clear(body)) +} + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum BadgeValue { + Dimensions(f64, f64), + Scale(f64), + Rotation(f64), +} + +impl DesignCanvas { + pub(crate) fn set_size_badge_obstacle(&self, rect: Option) { + if self.size_badge_obstacle.replace(rect) != rect { self.invalidate_visual(); } + } + + pub(super) fn draw_size_badge( + &self, + ctx: &mut DrawingContext, + value: BadgeValue, + bounds: Rect, + view: Viewport, + ) { + if !bounds.intersects(Rect::new(0.0, 0.0, view.width, view.height)) { + return; + } + // The readout is measured in screen DIPs. Panning, zoom and pointer hover + // reposition it without rebuilding text runs or reshaping the same numbers. + let mut cached = self.size_badge.borrow_mut(); + if cached.as_ref().is_none_or(|(previous, _)| *previous != value) { + let text = match value { + BadgeValue::Dimensions(width, height) => dimensions(width, height), + BadgeValue::Rotation(delta) => format!("Δ {:+.1}°", if delta.abs() < 0.05 { 0.0 } else { delta }), + BadgeValue::Scale(factor) => format!("{:.1}%", factor * 100.0), + }; + let label = FormattedText::new( + &text, + CultureInfo::invariant_culture(), + FlowDirection::LeftToRight, + Typeface::from_name("fonts:Inter#Inter"), + 11.0, + Some(brush("#FFFFFF")), + ); + *cached = Some((value, label)); + } + let label = &cached.as_ref().unwrap().1; + let Some(body) = badge_rect(bounds, view, label.width().ceil() + 14.0, label.height().ceil() + 6.0, self.size_badge_obstacle.get()) + else { + return; + }; + ctx.draw_rectangle(Some(&brush("#096DAE")), None, rect(body), 4.0, 4.0, &BoxShadows::default()); + ctx.draw_text(label, ui(Point::new(body.x + 7.0, body.y + 3.0))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resize_badge_preserves_fractional_dimensions_and_stays_with_visible_artwork() { + assert_eq!(dimensions(100.0, 64.5), "100 × 64.5"); + assert_eq!(dimensions(19.1251, 7.004), "19.13 × 7"); + let view = Viewport { width: 200.0, height: 140.0, ..Viewport::default() }; + let partial = Rect::new(-30.0, 15.0, 55.0, 50.0); + assert_eq!(badge_rect(partial, view, 70.0, 20.0, None), Some(Rect::new(4.0, 73.0, 70.0, 20.0))); + let clipped = Rect::new(140.0, 95.0, 180.0, 180.0); + assert_eq!(badge_rect(clipped, view, 70.0, 20.0, None), Some(Rect::new(126.0, 112.0, 70.0, 20.0))); + assert_eq!(badge_rect(Rect::new(210.0, 0.0, 80.0, 100.0), view, 70.0, 20.0, None), None); + } + + #[test] + fn size_badge_uses_measured_dock_footprint_without_reserving_the_whole_footer() { + let view = Viewport { width: 960.0, height: 600.0, ..Default::default() }; + let bounds = Rect::new(120.0, 50.0, 720.0, 475.0); + let dock = Rect::new(300.0, 540.0, 360.0, 48.0); + let place = |bounds, dock| badge_rect(bounds, view, 82.0, 22.0, dock).unwrap(); + let ordinary = place(bounds, None); + assert!(ordinary.intersects(dock)); + let adjusted = place(bounds, Some(dock)); + assert_eq!(adjusted.x, ordinary.x); + assert!(adjusted.bottom() <= bounds.bottom() - 8.0); + assert!(!adjusted.intersects(dock)); + let beside = Rect::new(45.0, 50.0, 100.0, 475.0); + assert_eq!(place(beside, Some(dock)), place(beside, None)); + let narrow = Viewport { width: 320.0, height: 320.0, ..Default::default() }; + let bounds = Rect::new(-15.0, 270.0, 350.0, 100.0); + let dock = Rect::new(0.0, 265.0, 320.0, 55.0); + let adjusted = badge_rect(bounds, narrow, 82.0, 22.0, Some(dock)).unwrap(); + assert_eq!(adjusted.bottom(), dock.y - 4.0); + assert!(badge_rect(bounds, narrow, 82.0, 22.0, Some(Rect::new(0.0,0.0,320.0,320.0))).is_none()); + } + + #[test] + fn resize_edges_preserve_affine_anchors_and_exact_unedited_dimensions() { + let mut doc = core::Document::new("Transformed edge drags"); + let mut parent = Node::new(NodeKind::Frame, "Parent", 110.0, 50.0, 450.0, 310.0); + parent.rotation = 23.0; + parent.affine_transform = Some(core::Transform { c: 0.35, ..core::Transform::IDENTITY }); + let parent = doc.insert_node(1, parent).unwrap(); + let mut layer = Node::new(NodeKind::Rectangle, "Layer", 35.0, 42.0, 64.5, 32.25); + layer.rotation = -11.0; + layer.flip_x = true; + let id = doc.insert_node(parent, layer).unwrap(); + let original = doc.nodes[&id].clone(); + let transform = doc.world_transform(id).unwrap(); + let canvas = DesignCanvas::new(Rc::new(RefCell::new(Editor::new(doc)))); + canvas.editor.borrow_mut().selection = vec![id]; + canvas.viewport.set(Viewport { zoom: 2.0, ..Viewport::default() }); + canvas.snap.set(false); + for edge in 0..4 { + let axes = Handle::Edge(edge).axes(); + let grab = original.local_bounds().center() + + Point::new(original.width * axes.x * 0.5, original.height * axes.y * 0.5); + let world = transform.apply(grab); + let hit = canvas.handle_at(world).unwrap(); + assert_eq!(hit.handle, Handle::Edge(edge)); + let delta = Point::new( + if axes.x == 0.0 { 13.0 } else { 10.0 * axes.x }, + if axes.y == 0.0 { 11.0 } else { 10.0 * axes.y }, + ); + for mods in [ + KeyModifiers::empty(), + KeyModifiers::SHIFT, + KeyModifiers::ALT, + KeyModifiers::SHIFT | KeyModifiers::ALT, + ] { + let centered = mods.contains(KeyModifiers::ALT); + let mut size = Point::new(original.width, original.height); + if axes.x != 0.0 { + size.x += if centered { 20.0 } else { 10.0 }; + } + if axes.y != 0.0 { + size.y += if centered { 20.0 } else { 10.0 }; + } + if mods.contains(KeyModifiers::SHIFT) { + if axes.x != 0.0 { + size.y = size.x / 2.0; + } else { + size.x = size.y * 2.0; + } + } + let mut drag = ResizeDrag::new(&mut canvas.editor.borrow_mut(), hit, world).unwrap(); + let anchor = transform.apply(drag.anchor(original.width, original.height, centered)); + drag.update(&canvas, transform.apply(grab + delta), mods); + let editor = canvas.editor.borrow(); + let current = &editor.document.nodes[&id]; + assert!((size.x - current.width).abs() < 1e-8); + assert!((size.y - current.height).abs() < 1e-8); + let new_anchor = drag.anchor(current.width, current.height, centered); + assert!( + anchor.distance(editor.document.world_transform(id).unwrap().apply(new_anchor)) < 1e-8 + ); + drop(editor); + canvas.editor.borrow_mut().cancel_transaction(); + } + } + assert_eq!(canvas.editor.borrow().history_len(), 0); + canvas.editor.borrow_mut().update_node(parent, "Lock parent", |n| n.locked = true); + assert!(canvas.handle_at(transform.apply(Point::new(0.0, original.height / 2.0))).is_none()); + } + + #[test] + fn resize_edge_hit_width_and_directional_cursor_follow_the_screen() { + let mut doc = core::Document::new("Resize cursor"); + let mut layer = Node::new(NodeKind::Rectangle, "Rotated", 200.0, 180.0, 128.5, 60.25); + layer.rotation = 90.0; + let transform = layer.local_transform(); + let id = doc.insert_node(1, layer).unwrap(); + let canvas = DesignCanvas::new(Rc::new(RefCell::new(Editor::new(doc)))); + canvas.editor.borrow_mut().selection = vec![id]; + for zoom in [0.5, 1.0, 4.0] { + canvas.viewport.set(Viewport { zoom, ..Viewport::default() }); + let right = transform.apply(Point::new(128.5 + 3.0 / zoom, 16.0)); + let hit = canvas.handle_at(right).unwrap(); + assert_eq!(hit.handle, Handle::Edge(1)); + assert_eq!(hit.cursor(), StandardCursorType::SizeNorthSouth); + assert!(canvas.handle_at(transform.apply(Point::new(128.5 + 5.0 / zoom, 24.0))).is_none()); + assert_eq!( + canvas.handle_at(transform.apply(Point::new(128.5, 0.0))).unwrap().handle, + Handle::Corner(1) + ); + } + } +} diff --git a/crates/ferrodesign-app/src/canvas/rotation.rs b/crates/ferrodesign-app/src/canvas/rotation.rs new file mode 100644 index 0000000..2809da0 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/rotation.rs @@ -0,0 +1,561 @@ +//! Selection rotation and its editor-only origin. World geometry drives the +//! transform; target, handle, and hit tolerances stay in screen DIPs. +use super::*; +use ferroui_base::input::StandardCursorType; + +#[derive(Default)] +pub(super) struct RotationState { + shown: Cell, + cache: RefCell>, + origin: RefCell>, +} + +struct Geometry { + token: core::EditorRenderToken, + page: NodeId, + selected: Vec, + bounds: Option, + orbit_center: Option, +} + +#[derive(Clone)] +struct Origin { + token: core::EditorRenderToken, + page: NodeId, + selected: Vec, + layer: NodeId, + relative: Point, +} + +impl Origin { + fn belongs(&self, e: &Editor) -> bool { + e.active_page == self.page && self.selected == e.selection && e.owns_render_token(&self.token) + } +} + +pub(super) struct OriginDrag { + before: Option, + bounds: core::RotationBounds, + start: Point, + press: Point, + token: core::EditorRenderToken, + selected: Vec, +} + +pub(super) struct Drag { + original: core::RotationBounds, + pivot: Point, + press: Point, + start_angle: f64, + screen: Point, + token: core::EditorRenderToken, + selected: Vec, + session: Option, + delta: f64, + painted: bool, + modifiers: KeyModifiers, + clicks: i32, +} + +#[derive(Clone, Copy)] +enum Hit { + Origin, + Grip, + Outside, +} + +fn geometry_handle(c: [Point; 4], zoom: f64) -> (Point, Point) { + let top = (c[0] + c[1]) * 0.5; + let mut outward = top - (c[0] + c[2]) * 0.5; + if outward.distance(Point::default()) < 1e-9 { + let edge = c[1] - c[0]; + outward = Point::new(edge.y, -edge.x); + } + let norm = outward.distance(Point::default()); + if norm < 1e-9 { + outward = Point::new(0.0, -1.0); + } else { + outward = outward * (1.0 / norm); + } + (top, top + outward * (24.0 / zoom)) +} + +fn outside_corner(c: [Point; 4], world: Point, zoom: f64) -> bool { + (0..4).any(|i| { + let delta = world - c[i]; + let distance = delta.distance(Point::default()) * zoom; + let left = c[(i + 3) % 4] - c[i]; + let right = c[(i + 1) % 4] - c[i]; + (8.0..=19.0).contains(&distance) + && delta.x * left.x + delta.y * left.y < 0.0 + && delta.x * right.x + delta.y * right.y < 0.0 + }) +} + +impl RotationState { + fn geometry(&self, e: &Editor) -> Option { + let token = e.render_change_token(); + let mut cached = self.cache.borrow_mut(); + if let Some(old) = cached.as_ref() { + if old.token == token && old.page == e.active_page && old.selected == e.selection { + return old.bounds; + } + } + if self + .origin + .borrow() + .as_ref() + .is_some_and(|origin| !origin.belongs(e)) + { + self.origin.replace(None); + } + let bounds = e.rotation_bounds(); + *cached = Some(Geometry { + token, + page: e.active_page, + selected: e.selection.clone(), + bounds, + orbit_center: None, + }); + bounds + } + + fn default_pivot(&self, b: core::RotationBounds) -> Point { + self.cache + .borrow() + .as_ref() + .and_then(|entry| entry.orbit_center) + .unwrap_or_else(|| b.center()) + } + + // An asymmetric selection's axis-aligned bounds can drift as it turns. + // Consecutive turns keep their original center until a different edit + // or selection invalidates the geometry cache. + fn finish_turn(&self, editor: &Editor, pivot: Point) { + if editor.selection.len() > 1 && self.origin.borrow().is_none() { + self.geometry(editor); + if let Some(entry) = self.cache.borrow_mut().as_mut() { + entry.orbit_center = Some(pivot); + } + } + } + + fn pivot(&self, e: &Editor, b: core::RotationBounds) -> Point { + let origin = self.origin.borrow(); + let Some(o) = origin + .as_ref() + .filter(|o| b.free_origin && o.belongs(e) && o.layer == b.anchor_layer) + else { + return self.default_pivot(b); + }; + let n = &e.document.nodes[&o.layer]; + let extent = |v| if v == 0.0 { 1.0 } else { v }; + e.scene.transforms[&o.layer].apply(Point::new( + o.relative.x * extent(n.width), + o.relative.y * extent(n.height), + )) + } + + fn set_pivot(&self, e: &Editor, b: core::RotationBounds, world: Point) { + if world == self.default_pivot(b) { + self.origin.replace(None); + return; + } + let Some(inverse) = e.scene.transforms[&b.anchor_layer].inverse() else { + return; + }; + let p = inverse.apply(world); + let n = &e.document.nodes[&b.anchor_layer]; + let relative = Point::new( + if n.width == 0.0 { p.x } else { p.x / n.width }, + if n.height == 0.0 { p.y } else { p.y / n.height }, + ); + if !relative.x.is_finite() || !relative.y.is_finite() { + return; + } + let mut slot = self.origin.borrow_mut(); + if let Some(o) = slot + .as_mut() + .filter(|o| o.layer == b.anchor_layer && o.belongs(e)) + { + o.relative = relative; + return; + } + *slot = Some(Origin { + token: e.render_change_token(), + page: e.active_page, + selected: e.selection.clone(), + layer: b.anchor_layer, + relative, + }); + } +} + +impl DesignCanvas { + fn rotation_available(&self) -> bool { + self.tool.get() == Tool::Select + && !self.space.get() + && self.presentation.get().is_none() + && self.vector_node.get().is_none() + && self.pen_node.get().is_none() + && self.image_crop.get().is_none() + && !self.stroke_width_mode_active() + && !self.linear_gradient_active() + } + + fn rotation_hit(&self, world: Point) -> Option<(Hit, core::RotationBounds, Point)> { + if !self.rotation_available() { + return None; + } + let e = self.editor.borrow(); + if e.has_active_transaction() { + return None; + } + let bounds = self.rotation.geometry(&e)?; + let pivot = self.rotation.pivot(&e, bounds); + let zoom = self.viewport.get().zoom; + let hit = if self.rotation.shown.get() && bounds.free_origin && world.distance(pivot) * zoom <= 8.0 { + Hit::Origin + } else if geometry_handle(bounds.corners, zoom).1.distance(world) * zoom <= 7.0 { + Hit::Grip + } else if outside_corner(bounds.corners, world, zoom) { + Hit::Outside + } else { + return None; + }; + Some((hit, bounds, pivot)) + } + + pub(super) fn hover_rotation(&self, world: Point) -> bool { + let Some((hit, _, _)) = self.rotation_hit(world) else { + return false; + }; + self.set_handle_feedback( + Some(match hit { + Hit::Origin => StandardCursorType::Cross, + _ => StandardCursorType::Hand, + }), + None, + ); + self.clear_measurement(); + self.clear_corner_radius_hover(); + if self.hover.take().is_some() { + self.invalidate_visual(); + } + true + } + + pub(super) fn press_rotation( + &self, + world: Point, + screen: Point, + modifiers: KeyModifiers, + clicks: i32, + ) -> bool { + let Some((hit, bounds, pivot)) = self.rotation_hit(world) else { + return false; + }; + let e = self.editor.borrow(); + let token = e.render_change_token(); + let selected = e.selection.clone(); + *self.gesture.borrow_mut() = match hit { + Hit::Origin => Gesture::RotationOrigin(OriginDrag { + before: self.rotation.origin.borrow().clone(), + bounds, + start: pivot, + press: world, + token, + selected, + }), + Hit::Grip | Hit::Outside => { + let axis = bounds.corners[1] - bounds.corners[0]; + Gesture::Rotate(Drag { + original: bounds, + pivot, + press: world, + start_angle: axis.y.atan2(axis.x).to_degrees(), + screen, + token, + selected, + session: None, + delta: 0.0, + painted: matches!(hit, Hit::Grip), + modifiers, + clicks, + }) + } + }; + self.invalidate_visual(); + true + } + + pub(super) fn rotation_key(&self, e: &KeyEventArgs) -> bool { + let alt = e.key_modifiers.contains(KeyModifiers::ALT); + let cmd = e + .key_modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::META); + if e.key == Key::Escape + && e.key_modifiers.is_empty() + && self.rotation.shown.get() + && self.rotation_available() + && matches!(*self.gesture.borrow(), Gesture::None) + { + self.rotation.shown.set(false); + self.invalidate_visual(); + return true; + } + let toggle = (e.key == Key::R || e.physical_key == ferroui_base::input::PhysicalKey::R) + && e.key_modifiers == KeyModifiers::ALT; + let keyboard = alt && cmd && matches!(e.key, Key::Up | Key::Down); + if !toggle && !keyboard { + return false; + } + if !self.rotation_available() || !matches!(*self.gesture.borrow(), Gesture::None) { + return true; + } + if toggle { + self.rotation.shown.set(!self.rotation.shown.get()); + self.clear_measurement(); + self.invalidate_visual(); + } else { + let mut editor = self.editor.borrow_mut(); + let Some(b) = self.rotation.geometry(&editor) else { + return true; + }; + let pivot = self.rotation.pivot(&editor, b); + let step = if e.key_modifiers.contains(KeyModifiers::SHIFT) { + 15.0 + } else { + 1.0 + }; + let degrees = if e.key == Key::Up { -step } else { step }; + let changed = editor.rotate_selection_about(Some(pivot), degrees); + if changed { + self.rotation.finish_turn(&editor, pivot); + } + drop(editor); + if changed { + self.invalidate_visual(); + self.emit(CanvasEvent::Document); + } + } + true + } + + pub(super) fn rotation_modifier(&self, event: &KeyEventArgs, pressed: bool) -> bool { + if !matches!(event.key, Key::LeftShift | Key::RightShift) { + return false; + } + let mut g = self.gesture.borrow_mut(); + let Gesture::Rotate(d) = &mut *g else { return false }; + let old = self.editor.borrow().revision; + if let Some(screen) = self.selection_pointer.get() { + let mut mods = event.key_modifiers; + mods.set(KeyModifiers::SHIFT, pressed); + d.update(self, self.viewport.get().screen_to_world(screen), mods); + } + drop(g); + if self.editor.borrow().revision != old { + self.emit(CanvasEvent::ResizePreview); + } + self.invalidate_visual(); + true + } + + pub(super) fn render_rotation(&self, ctx: &mut DrawingContext, e: &Editor, v: Viewport, g: &Gesture) { + if !self.rotation_available() { + return; + } + let (corners, pivot, show_target, delta) = match g { + Gesture::Rotate(d) => { + let orbit = core::Transform::translation(d.pivot.x, d.pivot.y) + * core::Transform::rotation(d.delta) + * core::Transform::translation(-d.pivot.x, -d.pivot.y); + ( + d.original.corners.map(|p| orbit.apply(p)), + d.pivot, + true, + Some(d.delta), + ) + } + Gesture::None | Gesture::RotationOrigin(_) => { + let Some(b) = self.rotation.geometry(e) else { + return; + }; + ( + b.corners, + self.rotation.pivot(e, b), + self.rotation.shown.get() && b.free_origin, + None, + ) + } + _ => return, + }; + let outline = pen(0xFF0D99FF, 1.0); + let white = brush("#FFFFFF"); + if delta.is_some() && e.selection.len() > 1 { + let p = corners.map(|p| ui(v.world_to_screen(p))); + for i in 0..4 { + ctx.draw_line(&outline, p[i], p[(i + 1) % 4]); + } + } + let (top, grip) = geometry_handle(corners, v.zoom); + ctx.draw_line(&outline, ui(v.world_to_screen(top)), ui(v.world_to_screen(grip))); + ctx.draw_ellipse_at( + Some(&white), + Some(&outline), + ui(v.world_to_screen(grip)), + 3.5, + 3.5, + ); + if show_target { + let p = v.world_to_screen(pivot); + let ink = pen(0xFF096DAE, 1.5); + ctx.draw_ellipse_at(Some(&white), Some(&ink), ui(p), 5.0, 5.0); + for axis in [Point::new(1., 0.), Point::new(0., 1.)] { + for sign in [-1., 1.] { + ctx.draw_line(&ink, ui(p + axis * (3. * sign)), ui(p + axis * (8. * sign))); + } + } + } + if let Some(degrees) = delta { + let b = corners + .into_iter() + .map(|p| { + let s = v.world_to_screen(p); + Rect::new(s.x, s.y, 0., 0.) + }) + .reduce(Rect::union) + .unwrap(); + self.draw_size_badge(ctx, resize::BadgeValue::Rotation(degrees), b, v); + } + } +} + +impl OriginDrag { + pub(super) fn update(&self, canvas: &DesignCanvas, p: Point, modifiers: KeyModifiers) { + let e = canvas.editor.borrow(); + if self.selected != e.selection || self.token != e.render_change_token() { + return; + } + if p == self.press { + canvas.rotation.origin.replace(self.before.clone()); + return; + } + let mut point = self.start + (p - self.press); + if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) { + let c = self.bounds.corners; + let anchors = [ + self.bounds.center(), + c[0], + c[1], + c[2], + c[3], + (c[0] + c[1]) * 0.5, + (c[1] + c[2]) * 0.5, + (c[2] + c[3]) * 0.5, + (c[3] + c[0]) * 0.5, + ]; + if let Some(near) = anchors + .into_iter() + .min_by(|a, b| a.distance(point).total_cmp(&b.distance(point))) + .filter(|a| a.distance(point) * canvas.viewport.get().zoom <= 6.) + { + point = near; + } + } + canvas.rotation.set_pivot(&e, self.bounds, point); + } + + pub(super) fn cancel(self, canvas: &DesignCanvas) { + canvas.rotation.origin.replace(self.before); + canvas.invalidate_visual(); + } +} + +impl Drag { + pub(super) fn update(&mut self, canvas: &DesignCanvas, world: Point, mods: KeyModifiers) { + let mut e = canvas.editor.borrow_mut(); + if self.session.is_none() { + if self.token != e.render_change_token() + || self.selected != e.selection + || canvas.viewport.get().world_to_screen(world).distance(self.screen) < 3.0 + { + return; + } + self.session = e.begin_selection_rotation(Some(self.pivot)); + } + let Some(session) = self.session.as_mut() else { + return; + }; + let from = self.press - self.pivot; + let to = world - self.pivot; + if from.distance(Point::default()) < 1e-9 || to.distance(Point::default()) < 1e-9 { + return; + } + let mut delta = (from.x * to.y - from.y * to.x) + .atan2(from.x * to.x + from.y * to.y) + .to_degrees(); + if mods.contains(KeyModifiers::SHIFT) { + delta = ((self.start_angle + delta) / 15.0).round() * 15.0 - self.start_angle; + } + if session.preview(&mut e, delta) { + self.delta = delta; + } + } + pub(super) fn cancel(self, canvas: &DesignCanvas) { + if let Some(s) = self.session { + s.cancel(&mut canvas.editor.borrow_mut()); + } + canvas.invalidate_visual(); + canvas.emit(CanvasEvent::Selection); + } + pub(super) fn finish(self, canvas: &DesignCanvas) { + if let Some(s) = self.session { + let changed = s.commit(&mut canvas.editor.borrow_mut()); + if changed { + canvas.rotation.finish_turn(&canvas.editor.borrow(), self.pivot); + } + canvas.emit(if changed { + CanvasEvent::Document + } else { + CanvasEvent::Selection + }); + } else if !self.painted + && self.selected == canvas.editor.borrow().selection + && self.token == canvas.editor.borrow().render_change_token() + && !canvas.cycle_pointer_click(self.press, self.modifiers) + { + canvas.select_at_pointer(self.press, self.modifiers, self.clicks, None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consecutive_keyboard_turns_keep_the_shared_center_of_an_asymmetric_selection() { + let mut doc = core::Document::new("Stable collective pivot"); + let a = doc + .insert_node(1, Node::new(NodeKind::Rectangle, "A", 5., 11., 55., 20.)) + .unwrap(); + let b = doc + .insert_node(1, Node::new(NodeKind::Rectangle, "B", 105., 90., 100., 14.)) + .unwrap(); + let mut e = Editor::new(doc); + e.selection = vec![a, b]; + let pivot = e.rotation_bounds().unwrap().center(); + let point = e.document.world_origin(a).unwrap(); + let canvas = DesignCanvas::new(Rc::new(RefCell::new(e))); + let mut event = KeyEventArgs::new(); + event.key = Key::Down; + event.key_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT; + for _ in 0..4 { + assert!(canvas.rotation_key(&event)); + } + let result = canvas.editor.borrow().document.world_origin(a).unwrap(); + assert!((pivot + core::Transform::rotation(60.).apply_vector(point - pivot)).distance(result) < 1e-8); + } +} diff --git a/crates/ferrodesign-app/src/canvas/rulers.rs b/crates/ferrodesign-app/src/canvas/rulers.rs new file mode 100644 index 0000000..19ae382 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/rulers.rs @@ -0,0 +1,603 @@ +//! Native ruler chrome and a transient drag. Only pointer release edits the file. +use super::*; +use ferrodesign_core::{Guide, RulerGuideId, Transform}; + +const WIDTH: f64 = 20.0; +const HIT: f64 = 4.0; + +#[derive(Clone)] +struct Selection { + key: RulerGuideId, + token: core::EditorRenderToken, + page: NodeId, +} + +struct Drag { + pointer: i32, + source: Option, + horizontal: bool, + start: Point, + grab_offset: Point, + moved: bool, + duplicate: bool, + measuring: bool, + pointer_world: Point, + token: core::EditorRenderToken, + page: NodeId, + preview: Option<(NodeId, Guide)>, +} + +#[derive(Default)] +pub(super) struct Rulers { + selected: Option, + drag: Option, +} + +impl DesignCanvas { + pub fn set_rulers_visible(&self, visible: bool) { + self.show_rulers.set(visible); + self.layout_grids.borrow_mut().rulers_visible = visible; + if !visible { + *self.rulers.borrow_mut() = Rulers::default(); + } + self.invalidate_visual(); + } + + pub fn selected_ruler_guide(&self) -> Option { + let editor = self.editor.borrow(); + let rulers = self.rulers.borrow(); + let s = rulers.selected.as_ref()?; + (s.page == editor.active_page + && s.token == editor.render_change_token() + && editor.document.ruler_guide(s.key).is_some()) + .then_some(s.key) + } + + pub fn select_ruler_guide(&self, id: Option) { + let editor = self.editor.borrow(); + self.rulers.borrow_mut().selected = id.map(|key| Selection { + key, + token: editor.render_change_token(), + page: editor.active_page, + }); + self.invalidate_visual(); + } + + pub(super) fn guide_drag_active(&self) -> bool { + self.rulers.borrow().drag.is_some() + } + + pub(super) fn cancel_guide_drag(&self) -> bool { + let cancelled = self.rulers.borrow_mut().drag.take().is_some(); + if cancelled { + self.invalidate_visual(); + } + cancelled + } + + pub(super) fn forget_selected_ruler(&self) { + if self.rulers.borrow_mut().selected.take().is_some() { + self.invalidate_visual(); + } + } + pub(super) fn lost_ruler_capture(&self, pointer: i32) -> bool { + let owned = self + .rulers + .borrow() + .drag + .as_ref() + .is_some_and(|d| d.pointer == pointer); + owned && self.cancel_guide_drag() + } + + pub(super) fn guide_at(&self, screen: Point) -> Option { + let e = self.editor.borrow(); + let v = self.viewport.get(); + let world = v.screen_to_world(screen); + let near = Rect::new(world.x, world.y, 0., 0.).inflate(HIT / v.zoom); + let mut nearest = None; + let mut distance = HIT / v.zoom; + for (owner, guides) in e.ruler_guides_in(near) { + if e.document.effective_locked(owner) { + continue; + } + for (index, &guide) in guides.iter().enumerate() { + if let Some((a, b)) = + e.document + .ruler_guide_segment(e.active_page, owner, guide, near) + { + let d = core::distance_to_segment(world, a, b); + if d <= distance { + distance = d; + nearest = Some(RulerGuideId { owner, index }); + } + } + } + } + nearest + } + + pub(super) fn press_ruler( + &self, + screen: Point, + pointer: i32, + modifiers: KeyModifiers, + strip_only: bool, + ) -> bool { + if !self.show_rulers.get() || self.editor.borrow().has_active_transaction() { + return false; + } + let top = screen.y < WIDTH; + let left = screen.x < WIDTH; + if strip_only != (top || left) { + return false; + } + if top && left { + return true; + } + let source = if strip_only { + None + } else { + if self.tool.get() != Tool::Select || self.vector_node.get().is_some() { + return false; + } + let Some(id) = self.guide_at(screen) else { + return false; + }; + Some(id) + }; + let editor = self.editor.borrow(); + let preview = source.and_then(|id| { + editor + .document + .ruler_guide(id) + .copied() + .map(|g| (id.owner, g)) + }); + let horizontal = preview.map_or(top, |(_, g)| g.horizontal); + let world = self.viewport.get().screen_to_world(screen); + let grab_offset = preview + .and_then(|(owner, guide)| { + let t = editor + .document + .ruler_owner_transform(editor.active_page, owner)?; + let mut local = t.inverse()?.apply(world); + if horizontal { + local.y = guide.position; + } else { + local.x = guide.position; + } + Some(t.apply(local) - world) + }) + .unwrap_or_default(); + self.rulers.borrow_mut().drag = Some(Drag { + pointer, + source, + horizontal, + start: screen, + grab_offset, + moved: false, + duplicate: source.is_some() && modifiers.contains(KeyModifiers::ALT), + measuring: modifiers.contains(KeyModifiers::ALT), + pointer_world: world, + page: editor.active_page, + token: editor.render_change_token(), + preview, + }); + self.invalidate_visual(); + true + } + + pub(super) fn move_ruler(&self, screen: Point, pointer: i32, modifiers: KeyModifiers) -> bool { + let (horizontal, page, grab_offset) = { + let mut rulers = self.rulers.borrow_mut(); + let Some(drag) = rulers.drag.as_mut() else { + return false; + }; + if drag.pointer != pointer { + return true; + } + drag.measuring = modifiers.contains(KeyModifiers::ALT); + drag.pointer_world = self.viewport.get().screen_to_world(screen); + if drag.start.distance(screen) >= 3.0 { + drag.moved = true; + } + if !drag.moved { + return true; + } + if screen == drag.start { + drag.preview = drag.source.and_then(|id| { + self.editor + .borrow() + .document + .ruler_guide(id) + .copied() + .map(|g| (id.owner, g)) + }); + self.invalidate_visual(); + return true; + } + (drag.horizontal, drag.page, drag.grab_offset) + }; + let v = self.viewport.get(); + let preview = if screen.x >= WIDTH + && screen.y >= WIDTH + && screen.x < v.width + && screen.y < v.height + { + let world = v.screen_to_world(screen); + let owner = self + .containing_parent_at(world, |editor, id| { + !editor.document.effective_locked(id) + && editor.document.ruler_owner_transform(page, id).is_some() + }) + .unwrap_or(page); + let e = self.editor.borrow(); + e.document + .ruler_owner_transform(page, owner) + .and_then(Transform::inverse) + .map(|inv| { + let local = inv.apply(world + grab_offset); + let coordinate = if horizontal { local.y } else { local.x }; + let mut position = if self.snap.get() { + coordinate.round() + } else { + coordinate + }; + if let Some(node) = e.document.nodes.get(&owner) { + position = + position.clamp(0., if horizontal { node.height } else { node.width }); + } + ( + owner, + Guide { + horizontal, + position, + }, + ) + }) + } else { + None + }; + if let Some(drag) = self.rulers.borrow_mut().drag.as_mut() { + drag.preview = preview; + } + self.invalidate_visual(); + true + } + + pub(super) fn release_ruler( + &self, + screen: Point, + pointer: i32, + modifiers: KeyModifiers, + ) -> bool { + if !self.move_ruler(screen, pointer, modifiers) { + return false; + } + if self + .rulers + .borrow() + .drag + .as_ref() + .is_some_and(|d| d.pointer != pointer) + { + return true; + } + let Some(drag) = self.rulers.borrow_mut().drag.take() else { + return true; + }; + if self.editor.borrow().active_page != drag.page + || self.editor.borrow().render_change_token() != drag.token + { + self.invalidate_visual(); + return true; + } + if !drag.moved { + self.select_ruler_guide(drag.source); + return true; + } + let v = self.viewport.get(); + // A drop outside the canvas cancels; dropping back on a ruler deletes. + if screen.x < 0. || screen.y < 0. || screen.x >= v.width || screen.y >= v.height { + self.invalidate_visual(); + return true; + } + self.commit_ruler(drag.source, drag.preview, drag.duplicate); + true + } + + pub fn commit_ruler( + &self, + source: Option, + dest: Option<(NodeId, Guide)>, + duplicate: bool, + ) -> bool { + let mut editor = self.editor.borrow_mut(); + let before = editor.revision; + let result = editor.edit_ruler_guide(source, dest, duplicate); + let changed = editor.revision != before; + drop(editor); + match result { + Ok(selected) => { + self.select_ruler_guide(selected); + if changed { + self.emit(CanvasEvent::Document); + } + true + } + Err(err) => { + self.emit(CanvasEvent::Notice(err.to_string())); + self.invalidate_visual(); + false + } + } + } + + pub(super) fn ruler_alt(&self, key: Key, pressed: bool) -> bool { + if !matches!(key, Key::LeftAlt | Key::RightAlt) { + return false; + } + let mut state = self.rulers.borrow_mut(); + let Some(drag) = state.drag.as_mut() else { + return false; + }; + drag.measuring = pressed; + drop(state); + self.invalidate_visual(); + true + } + + pub(crate) fn ruler_redlines(&self, e: &Editor, view: Viewport) -> Option { + let state = self.rulers.borrow(); + let drag = state.drag.as_ref().filter(|d| { + d.moved && d.measuring && d.page == e.active_page && d.token == e.render_change_token() + })?; + let (owner, guide) = drag.preview?; + let selected = e.selection.last().copied(); + let hit = e.hit_test(drag.pointer_world, 0.); + // Once a guide enters a frame, the artwork beneath the pointer takes + // precedence. In empty canvas space the selected frame remains the target. + let target = hit.filter(|&id| id != owner).or(selected).or(hit)?; + e.document.ruler_distances( + drag.page, + owner, + guide, + target, + drag.pointer_world, + view.visible_rect(), + ) + } + + pub(super) fn ruler_key(&self, e: &KeyEventArgs) -> bool { + if e.key == Key::R && e.key_modifiers == KeyModifiers::SHIFT { + self.set_rulers_visible(!self.show_rulers.get()); + return true; + } + if !self.show_rulers.get() || self.guide_drag_active() { + return false; + } + let Some(id) = self.selected_ruler_guide() else { + return false; + }; + if e.key_modifiers + .intersects(KeyModifiers::ALT | KeyModifiers::META | KeyModifiers::CONTROL) + { + return false; + } + match e.key { + Key::Escape => { + self.select_ruler_guide(None); + } + Key::Delete | Key::Back => { + self.commit_ruler(Some(id), None, false); + } + Key::Enter => self.emit(CanvasEvent::Action("ruler-guide-settings")), + Key::Up | Key::Down | Key::Left | Key::Right => { + let guide = self + .editor + .borrow() + .document + .ruler_guide(id) + .copied() + .unwrap(); + let sign = match (guide.horizontal, e.key) { + (true, Key::Down) | (false, Key::Right) => 1., + (true, Key::Up) | (false, Key::Left) => -1., + _ => return true, + }; + let step = self.nudge.get().step(e.key_modifiers.contains(KeyModifiers::SHIFT)); + let mut position = guide.position + sign * step; + if let Some(node) = self.editor.borrow().document.nodes.get(&id.owner) { + position = position.clamp( + 0., + if guide.horizontal { + node.height + } else { + node.width + }, + ); + } + self.commit_ruler( + Some(id), + Some((id.owner, Guide { position, ..guide })), + false, + ); + } + _ => return false, + } + true + } + + pub(super) fn render_ruler_guides(&self, ctx: &mut DrawingContext, e: &Editor, v: Viewport) { + if !self.show_rulers.get() { + return; + } + let state = self.rulers.borrow(); + let selected = state + .selected + .as_ref() + .filter(|s| s.page == e.active_page && s.token == e.render_change_token()) + .map(|s| s.key); + let drag = state + .drag + .as_ref() + .filter(|d| d.page == e.active_page && d.token == e.render_change_token() && d.moved); + let normal = pen(0xE6E64B68, 1.); + let active = pen(0xFF0D99FF, 1.5); + let draw = |ctx: &mut DrawingContext, owner, guide, highlight| { + if let Some((a, b)) = + e.document + .ruler_guide_segment(e.active_page, owner, guide, v.visible_rect()) + { + ctx.draw_line( + if highlight { &active } else { &normal }, + ui(v.world_to_screen(a)), + ui(v.world_to_screen(b)), + ); + } + }; + for (owner, guides) in e.ruler_guides_in(v.visible_rect()) { + for (index, &g) in guides.iter().enumerate() { + let key = RulerGuideId { owner, index }; + if drag.is_some_and(|d| !d.duplicate && d.source == Some(key)) { + continue; + } + draw(ctx, owner, g, Some(key) == selected); + } + } + if let Some((owner, g)) = drag.and_then(|d| d.preview) { + draw(ctx, owner, g, true); + } + } + + pub(super) fn render_rulers(&self, ctx: &mut DrawingContext, e: &Editor, v: Viewport) { + if !self.show_rulers.get() || v.width <= WIDTH || v.height <= WIDTH { + return; + } + let stock = brush("#F5F5F5"); + let tint = brush("#D9EDFF"); + let tick = pen(0xFF939393, 1.); + let border = pen(0xFFCBCBCB, 1.); + let bounds = e.selection_bounds(); + let world = v.visible_rect(); + let major = ruler_step(v.zoom); + for horizontal in [true, false] { + let span = if horizontal { v.width } else { v.height }; + let rect = if horizontal { + UiRect::new(0., 0., span, WIDTH) + } else { + UiRect::new(0., 0., WIDTH, span) + }; + ctx.draw_rectangle(Some(&stock), None, rect, 0., 0., &BoxShadows::default()); + if let Some(b) = bounds { + let a = v.world_to_screen(Point::new(b.x, b.y)); + let z = v.world_to_screen(Point::new(b.right(), b.bottom())); + let (lo, hi) = if horizontal { (a.x, z.x) } else { (a.y, z.y) }; + let (lo, hi) = (lo.max(WIDTH), hi.min(span)); + if hi > lo { + let range = if horizontal { + UiRect::new(lo, 0., hi - lo, WIDTH) + } else { + UiRect::new(0., lo, WIDTH, hi - lo) + }; + ctx.draw_rectangle(Some(&tint), None, range, 0., 0., &BoxShadows::default()); + } + } + let origin = if horizontal { v.pan.x } else { v.pan.y }; + let visible_start = if horizontal { world.x } else { world.y }; + let minor = major / 5.; + let first = (visible_start / minor).floor(); + let count = (span / (minor * v.zoom)).ceil() as usize + 2; + for i in 0..count { + let index = first + i as f64; + let pos = origin + index * minor * v.zoom; + if pos < WIDTH || pos >= span { + continue; + } + let big = index.rem_euclid(5.) == 0.; + let len = if big { 7. } else { 3. }; + let (a, b) = if horizontal { + (Point::new(pos, WIDTH - len), Point::new(pos, WIDTH)) + } else { + (Point::new(WIDTH - len, pos), Point::new(WIDTH, pos)) + }; + ctx.draw_line(&tick, ui(a), ui(b)); + if big { + let precision = (-major.log10().floor()).clamp(0., 6.) as usize; + let text = format!("{:.*}", precision, index * minor); + if horizontal { + label(ctx, &text, Point::new(pos + 2., 2.), 9., "#505050"); + } else { + let turn = ctx.push_transform( + Matrix::create_rotation(-std::f64::consts::FRAC_PI_2) + * Matrix::create_translation(2., pos - 2.), + ); + label(ctx, &text, Point::default(), 9., "#505050"); + ctx.pop(turn); + } + } + } + if horizontal { + ctx.draw_line( + &border, + ui(Point::new(WIDTH, WIDTH)), + ui(Point::new(span, WIDTH)), + ); + } else { + ctx.draw_line( + &border, + ui(Point::new(WIDTH, WIDTH)), + ui(Point::new(WIDTH, span)), + ); + } + } + ctx.draw_rectangle( + Some(&stock), + None, + UiRect::new(0., 0., WIDTH, WIDTH), + 0., + 0., + &BoxShadows::default(), + ); + if let Some((owner, g)) = self + .rulers + .borrow() + .drag + .as_ref() + .filter(|d| d.moved) + .and_then(|d| d.preview) + { + let Some((a, b)) = e + .document + .ruler_guide_segment(e.active_page, owner, g, world) + else { + return; + }; + let mid = v.world_to_screen(a.lerp(b, 0.5)); + let value = format!("{} {:.1}", if g.horizontal { "Y" } else { "X" }, g.position); + let w = value.len() as f64 * 7.0 + 10.; + let x = (mid.x + 5.).clamp(WIDTH, (v.width - w).max(WIDTH)); + let y = (mid.y - 22.).clamp(WIDTH, (v.height - 20.).max(WIDTH)); + ctx.draw_rectangle( + Some(&brush("#096DAE")), + None, + UiRect::new(x, y, w, 19.), + 3., + 3., + &BoxShadows::default(), + ); + label(ctx, &value, Point::new(x + 5., y + 2.), 10., "#FFFFFF"); + } + } +} + +fn ruler_step(zoom: f64) -> f64 { + let desired = 64. / zoom; + let unit = 10.0_f64.powf(desired.log10().floor()); + [1., 2., 5., 10.] + .into_iter() + .map(|n| n * unit) + .find(|n| *n >= desired) + .unwrap_or(unit * 10.) +} diff --git a/crates/ferrodesign-app/src/canvas/scale.rs b/crates/ferrodesign-app/src/canvas/scale.rs new file mode 100644 index 0000000..efcdfe7 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/scale.rs @@ -0,0 +1,226 @@ +//! Uniform scale uses a world-space selection box shared by hit testing and feedback. +use super::*; +use ferroui_base::input::StandardCursorType; + +pub(crate) fn anchor(bounds: Rect, index: usize) -> Point { + Point::new( + bounds.x + bounds.width * (index % 3) as f64 / 2.0, + bounds.y + bounds.height * (index / 3) as f64 / 2.0, + ) +} + +const HANDLES: [usize; 8] = [0, 2, 6, 8, 1, 3, 5, 7]; + +fn can_scale_from(bounds: Rect, index: usize) -> bool { + anchor(bounds, index) != anchor(bounds, 8 - index) +} + +pub(super) struct CachedBounds { + version: core::EditorRenderToken, + resize: bool, + page: NodeId, + selection: Vec, + bounds: Option, +} + +pub(super) fn box_handle_at(bounds: Rect, pointer: Point, tolerance: f64, side_tolerance: f64) -> Option { + // Prioritize painted corners over an edge when their hit areas meet. + [0, 2, 6, 8] + .into_iter() + .filter_map(|i| { + let distance = anchor(bounds, i).distance(pointer); + (distance <= tolerance && can_scale_from(bounds, i)).then_some((i, distance)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + .or_else(|| { + let x = (bounds.x..=bounds.x + bounds.width).contains(&pointer.x); + let y = (bounds.y..=bounds.y + bounds.height).contains(&pointer.y); + [ + (1, x, (pointer.y - bounds.y).abs()), + (7, x, (pointer.y - bounds.y - bounds.height).abs()), + (3, y, (pointer.x - bounds.x).abs()), + (5, y, (pointer.x - bounds.x - bounds.width).abs()), + ] + .into_iter() + .filter(|(i, within, distance)| { + *within && *distance <= side_tolerance && can_scale_from(bounds, *i) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(i, _, _)| i) + }) +} + +pub(super) struct ScaleDrag { + session: core::SelectionScale, + handle: usize, + press: Point, + factor: f64, + visible_bounds: Rect, +} +impl ScaleDrag { + pub(super) fn update(&mut self, editor: &mut Editor, pointer: Point, centered: bool) { + let bounds = self.session.bounds(); + let pivot = anchor(bounds, if centered { 4 } else { 8 - self.handle }); + let ray = anchor(bounds, self.handle) - pivot; + let length2 = ray.x * ray.x + ray.y * ray.y; + if length2 <= f64::EPSILON { + return; + } + // Project travel onto the original handle ray. Mid-edge handles use + // one axis and corners preserve aspect even off the diagonal. + let delta = pointer - self.press; + let factor = (1.0 + (delta.x * ray.x + delta.y * ray.y) / length2).max(0.001); + if self.session.preview(editor, pivot, factor) { + self.factor = factor; + let start = pivot + (anchor(bounds, 0) - pivot) * factor; + self.visible_bounds = Rect::new( + start.x, + start.y, + bounds.width * factor, + bounds.height * factor, + ); + } + } + pub(super) fn commit(self, editor: &mut Editor) -> bool { + self.session.commit(editor) + } +} + +impl DesignCanvas { + // Pointer moves and camera frames share one eligibility/hierarchy walk. + // Selection changes do not advance the editor's render token. + pub(super) fn transform_box(&self, editor: &Editor, resize: bool) -> Option { + if let Gesture::Scale(drag) = &*self.gesture.borrow() { + return Some(drag.visible_bounds); + } + if let Gesture::MultiResize(drag) = &*self.gesture.borrow() { + return Some(drag.visible); + } + let version = editor.render_change_token(); + let mut cache = self.scale_bounds.borrow_mut(); + if let Some(entry) = cache.as_ref() { + if entry.resize == resize && entry.version == version + && entry.page == editor.active_page + && entry.selection == editor.selection + { + return entry.bounds; + } + } + let bounds = if resize { editor.multi_resize_bounds() } else { editor.selection_scale_bounds() }; + *cache = Some(CachedBounds { + version, resize, + page: editor.active_page, + selection: editor.selection.clone(), + bounds, + }); + bounds + } + + pub(super) fn press_scale_handle(&self, world: Point) -> bool { + let mut editor = self.editor.borrow_mut(); + let Some(handle) = self + .transform_box(&editor, false) + .and_then(|bounds| box_handle_at(bounds, world, 7.0 / self.viewport.get().zoom, 7.0 / self.viewport.get().zoom)) + else { + return false; + }; + let Some(session) = editor.begin_scale_selection() else { + return false; + }; + let visible_bounds = session.bounds(); + *self.gesture.borrow_mut() = Gesture::Scale(ScaleDrag { + session, + handle, + press: world, + factor: 1.0, + visible_bounds, + }); + true + } + + pub(crate) fn cancel_scale_drag(&self) -> bool { + if !matches!(*self.gesture.borrow(), Gesture::Scale(_)) { + return false; + } + if let Gesture::Scale(drag) = self.gesture.replace(Gesture::None) { + drag.session.cancel(&mut self.editor.borrow_mut()); + self.invalidate_visual(); + self.emit(CanvasEvent::Selection); + } + true + } + + pub(super) fn update_scale_cursor(&self, world: Point) { + let handle = self + .transform_box(&self.editor.borrow(), false) + .and_then(|bounds| box_handle_at(bounds, world, 7.0 / self.viewport.get().zoom, 7.0 / self.viewport.get().zoom)); + let kind = match handle { + Some(0 | 8) => Some(StandardCursorType::TopLeftCorner), + Some(2 | 6) => Some(StandardCursorType::TopRightCorner), + Some(1 | 7) => Some(StandardCursorType::SizeNorthSouth), + Some(3 | 5) => Some(StandardCursorType::SizeWestEast), + _ => None, + }; + self.set_handle_feedback(kind, None); + } + + pub(super) fn render_scale_handles( + &self, + ctx: &mut DrawingContext, + editor: &Editor, + view: Viewport, + ) { + let resize = self.tool.get() == Tool::Select; + let Some(bounds) = self.transform_box(editor, resize) else { + return; + }; + let outline = pen(0xFF0D99FF, 1.0); + let white = brush("#FFFFFF"); + let top = view.world_to_screen(anchor(bounds, 0)); + ctx.draw_rectangle_outline( + &outline, + UiRect::new( + top.x, + top.y, + bounds.width * view.zoom, + bounds.height * view.zoom, + ), + 0.0, + ); + for index in HANDLES { + if !can_scale_from(bounds, index) { + continue; + } + let p = view.world_to_screen(anchor(bounds, index)); + ctx.draw_rectangle( + Some(&white), + Some(&outline), + UiRect::new(p.x - 3.0, p.y - 3.0, 6.0, 6.0), + 0.0, + 0.0, + &BoxShadows::default(), + ); + } + let screen = Rect::new(top.x, top.y, bounds.width * view.zoom, bounds.height * view.zoom); + if let Gesture::Scale(drag) = &*self.gesture.borrow() { + self.draw_size_badge(ctx, resize::BadgeValue::Scale(drag.factor), screen, view); + } else if resize && self.measurement_target().is_none() { + self.draw_size_badge(ctx, resize::BadgeValue::Dimensions(bounds.width, bounds.height), screen, view); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collapsed_sides_of_lines_do_not_offer_inert_scale_handles() { + let line = Rect::new(10.0, 20.0, 150.0, 0.0); + assert_eq!(box_handle_at(line, line.center(), 5.0, 5.0), None); + assert!(box_handle_at(line, anchor(line, 3), 5.0, 5.0).is_some()); + let point = Rect::new(10.0, 20.0, 0.0, 0.0); + assert_eq!(box_handle_at(point, point.center(), 5.0, 5.0), None); + } +} diff --git a/crates/ferrodesign-app/src/canvas/selection.rs b/crates/ferrodesign-app/src/canvas/selection.rs index fbda41f..ff06186 100644 --- a/crates/ferrodesign-app/src/canvas/selection.rs +++ b/crates/ferrodesign-app/src/canvas/selection.rs @@ -15,7 +15,64 @@ pub(super) struct CachedPrototypeSelection { builds: u64, } +pub(super) struct Marquee { + pub(super) start: Point, + pub(super) current: Point, + pub(super) preview: Vec, + page: NodeId, + token: core::EditorRenderToken, +} + +impl Marquee { + fn new(editor: &Editor, start: Point, additive: bool) -> Self { + Self { + start, current: start, page: editor.active_page, token: editor.render_change_token(), + preview: if additive { editor.selection.clone() } else { Vec::new() }, + } + } + + pub(super) fn update(&mut self, canvas: &DesignCanvas, editor: &Editor, point: Point, modifiers: KeyModifiers) -> bool { + self.current = point; + if self.page != editor.active_page || self.token != editor.render_change_token() + || editor.has_active_transaction() { + self.preview.clear(); + return false; + } + let additive = modifiers.contains(KeyModifiers::SHIFT); + self.preview = if self.start == point { + if additive { editor.selection.clone() } else { Vec::new() } + } else { + let rect = Rect::from_points(self.start, point); + let deep = modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META); + if canvas.show_outlines.get() { + let hits = canvas.outlines.borrow_mut().enclosed(editor, rect, canvas.outline_hidden.get()); + editor.marquee_from_enclosed(hits, additive, deep) + } else { + editor.marquee_selection(rect, additive, deep) + } + }; + true + } +} + impl DesignCanvas { + pub(super) fn marquee_modifier(&self, event: &KeyEventArgs, pressed: bool) -> bool { + let modifier = match event.key { + Key::LeftShift | Key::RightShift => KeyModifiers::SHIFT, + Key::LeftCtrl | Key::RightCtrl => KeyModifiers::CONTROL, + Key::LWin | Key::RWin => KeyModifiers::META, + _ => return false, + }; + let mut gesture = self.gesture.borrow_mut(); + let Gesture::Marquee(drag) = &mut *gesture else { return false }; + let mut modifiers = event.key_modifiers; + modifiers.set(modifier, pressed); + drag.update(self, &self.editor.borrow(), drag.current, modifiers); + drop(gesture); + self.invalidate_visual(); + true + } + pub fn prototype_selection_index_stats(&self) -> serde_json::Value { let cached = self.prototype_selection.borrow(); serde_json::json!({ @@ -58,6 +115,59 @@ impl DesignCanvas { ) } + pub(super) fn cycle_pointer_click(&self, world: Point, modifiers: KeyModifiers) -> bool { + if self.tool.get() == Tool::Scale || self.vector_node.get().is_some() || !modifiers.contains(KeyModifiers::ALT) + || modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) { + return false; + } + let direction = if modifiers.contains(KeyModifiers::SHIFT) { + core::SelectionCycle::InFront + } else { + core::SelectionCycle::Behind + }; + self.cycle_selection_at(world, direction); + true + } + + pub(super) fn select_at_pointer( + &self, world: Point, modifiers: KeyModifiers, clicks: i32, drag_origin: Option, + ) { + let additive = modifiers.contains(KeyModifiers::SHIFT); + let scaling = self.tool.get() == Tool::Scale; + let hit = self.pick_with_modifiers(world, modifiers, clicks == 2); + let mut editor = self.editor.borrow_mut(); + if let Some(id) = hit { + if additive || !editor.selection.contains(&id) { editor.select(id, additive); } + if !scaling && clicks == 2 { + match editor.document.nodes[&id].kind { + NodeKind::Text => { drop(editor); self.emit(CanvasEvent::EditText(id)); return; } + NodeKind::Vector => { drop(editor); self.edit_vector(); return; } + _ => {} + } + } + if drag_origin.is_some_and(|screen| !self.layout_drag_press(&editor, id, world, screen)) { + let exclude = editor.root_selection(); + let bounds = exclude.iter().filter_map(|&id| editor.document.world_bounds(id)).reduce(Rect::union); + let nodes = exclude.iter().filter_map(|id| editor.document.nodes.get(id).cloned()).collect(); + editor.begin_node_transaction(&exclude, "Move layers"); + if let Some(probe) = self.free_layout_probe.borrow_mut().as_mut() { probe.record_transaction(&editor); } + *self.gesture.borrow_mut() = Gesture::Move { start: world, nodes, bounds, exclude, moved: false }; + } + } else if drag_origin.is_some() { + if !editor.has_active_transaction() { + *self.gesture.borrow_mut() = Gesture::Marquee(Marquee::new(&editor, world, additive)); + self.hover.set(None); + self.invalidate_visual(); + } + return; + } else if !additive { + editor.clear_selection(); + } + drop(editor); + self.emit(CanvasEvent::Selection); + self.invalidate_visual(); + } + pub(super) fn pick(&self, point: Point) -> Option { self.pick_with_modifiers(point, KeyModifiers::empty(), false) } @@ -74,10 +184,19 @@ impl DesignCanvas { .page(editor.active_page)? .children .as_slice(); - let mut painter = self.painter.borrow_mut(); - let hit = editor.hit_test_filtered(point, 3.0 / self.viewport.get().zoom, |id| { - painter.mask_contains(&editor, roots, id, point) - })?; + let hit = if self.show_outlines.get() { + self.outlines.borrow_mut().hits(&editor, point, 3. / self.viewport.get().zoom, + self.outline_hidden.get(), self.outline_bounds.get(), false).into_iter().next() + } else { + let mut painter = self.painter.borrow_mut(); + editor.hit_test_filtered(point, 3.0 / self.viewport.get().zoom, |id| { + if editor.document.nodes[&id].kind == NodeKind::Slice { + slices::pick(&editor, id, point, 3.0 / self.viewport.get().zoom) + } else { + painter.mask_contains(&editor, roots, id, point) + } + }) + }?; if !self.select_groups.get() || modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META) { @@ -125,6 +244,10 @@ impl DesignCanvas { fn selection_candidates_at(&self, point: Point, include_locked: bool) -> Vec { let editor = self.editor.borrow(); + if self.show_outlines.get() { + return self.outlines.borrow_mut().hits(&editor, point, 3. / self.viewport.get().zoom, + self.outline_hidden.get(), self.outline_bounds.get(), include_locked); + } let Some(page) = editor.document.page(editor.active_page) else { return Vec::new(); }; @@ -138,7 +261,7 @@ impl DesignCanvas { include_locked, ) .into_iter() - .filter(|id| painter.mask_contains(&editor, &page.children, *id, point)) + .filter(|id| editor.document.nodes[id].kind == NodeKind::Slice || painter.mask_contains(&editor, &page.children, *id, point)) .collect() } @@ -151,7 +274,7 @@ impl DesignCanvas { let changed = self .editor .borrow_mut() - .cycle_selection(&candidates, direction); + .cycle_selection_with_visibility(&candidates, direction, self.show_outlines.get() && self.outline_hidden.get()); if changed { self.leave_vector_edit(); self.hover.set(None); diff --git a/crates/ferrodesign-app/src/canvas/selection_resize.rs b/crates/ferrodesign-app/src/canvas/selection_resize.rs new file mode 100644 index 0000000..62ec5cc --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/selection_resize.rs @@ -0,0 +1,234 @@ +use super::*; +use ferroui_base::input::StandardCursorType; + +pub(super) struct MultiResize { + handle: usize, + origin: Point, + screen: Point, + version: core::EditorRenderToken, + selected: Vec, + session: Option, + pub(super) visible: Rect, + painted: bool, + modifiers: KeyModifiers, + clicks: i32, +} + +impl DesignCanvas { + pub(super) fn multi_resize_handle(&self, world: Point) -> Option<(usize, Rect)> { + if self.tool.get() != Tool::Select + || self.space.get() + || self.vector_node.get().is_some() + || self.image_crop.get().is_some() + || self.presentation.get().is_some() + || self.pen_node.get().is_some() + || self.stroke_width_mode_active() + { + return None; + } + let editor = self.editor.borrow(); + if editor.selection.len() < 2 || editor.has_active_transaction() { + return None; + } + let bounds = self.transform_box(&editor, true)?; + let zoom = self.viewport.get().zoom; + Some(( + scale::box_handle_at(bounds, world, 7.0 / zoom, 4.0 / zoom)?, + bounds, + )) + } + + pub(super) fn multi_resize_hover(&self, world: Point) -> bool { + let Some((handle, _)) = self.multi_resize_handle(world) else { + return false; + }; + let cursor = match handle { + 0 | 8 => StandardCursorType::TopLeftCorner, + 2 | 6 => StandardCursorType::TopRightCorner, + 1 | 7 => StandardCursorType::SizeNorthSouth, + _ => StandardCursorType::SizeWestEast, + }; + self.set_handle_feedback(Some(cursor), None); + self.clear_measurement(); + self.clear_corner_radius_hover(); + if self.hover.take().is_some() { + self.invalidate_visual(); + } + true + } + + pub(super) fn press_multi_resize( + &self, + world: Point, + screen: Point, + modifiers: KeyModifiers, + clicks: i32, + ) -> bool { + let Some((handle, bounds)) = self.multi_resize_handle(world) else { + return false; + }; + let editor = self.editor.borrow(); + let painted = scale::anchor(bounds, handle).distance(world) * self.viewport.get().zoom <= 7.0; + *self.gesture.borrow_mut() = Gesture::MultiResize(MultiResize { + handle, + origin: world, + screen, + version: editor.render_change_token(), + selected: editor.selection.clone(), + session: None, + visible: bounds, + painted, + modifiers, + clicks, + }); + true + } + + pub(super) fn resize_modifier(&self, event: &KeyEventArgs, pressed: bool) -> bool { + let flag = match event.key { + Key::LeftAlt | Key::RightAlt => KeyModifiers::ALT, + Key::LeftShift | Key::RightShift => KeyModifiers::SHIFT, + Key::LeftCtrl | Key::RightCtrl => KeyModifiers::CONTROL, + Key::LWin | Key::RWin => KeyModifiers::META, + _ => return false, + }; + let mut gesture = self.gesture.borrow_mut(); + let Some(screen) = self.selection_pointer.get() else { return false; }; + let revision = self.editor.borrow().revision; + let mut mods = event.key_modifiers; + mods.set(flag, pressed); + let world = self.viewport.get().screen_to_world(screen); + match &mut *gesture { + Gesture::MultiResize(drag) => drag.update(self, world, mods), + Gesture::Resize(drag) => drag.update(self, world, mods), + _ => return false, + } + drop(gesture); + if self.editor.borrow().revision != revision { + self.emit(CanvasEvent::ResizePreview); + } + self.invalidate_visual(); + true + } +} + +impl MultiResize { + pub(super) fn update(&mut self, canvas: &DesignCanvas, world: Point, modifiers: KeyModifiers) { + let mut editor = canvas.editor.borrow_mut(); + if self.session.is_none() { + if self.screen.distance(canvas.viewport.get().world_to_screen(world)) < 3.0 + || self.version != editor.render_change_token() + || self.selected != editor.selection + { + return; + } + self.session = editor.begin_multi_resize(); + } + let Some(session) = &mut self.session else { return }; + let bounds = session.bounds(); + let centered = modifiers.contains(KeyModifiers::ALT); + let pivot = scale::anchor(bounds, if centered { 4 } else { 8 - self.handle }); + let ray = scale::anchor(bounds, self.handle) - pivot; + let delta = world - self.origin; + let factor = |span: f64, axis: f64, motion: f64| { + if axis == 0.0 || motion == 0.0 { + return 1.0; + } + let extent = span * (1.0 + motion / axis); + let extent = if canvas.snap.get() { extent.round() } else { extent }; + extent.max(span.min(1.0)) / span + }; + let mut f = Point::new( + factor(bounds.width, ray.x, delta.x), + factor(bounds.height, ray.y, delta.y), + ); + if modifiers.contains(KeyModifiers::SHIFT) { + let uniform = if ray.x == 0.0 { + f.y + } else if ray.y == 0.0 { + f.x + } else { + // Project onto the original diagonal, as the K tool does, so both inward + // and outward strokes remain responsive near horizontal and vertical motion. + let dot = delta.x * ray.x + delta.y * ray.y; + (1.0 + dot / (ray.x * ray.x + ray.y * ray.y)) + .max((1.0 / bounds.width.max(bounds.height)).min(1.0)) + }; + f = Point::new(uniform, uniform); + } + let mut grids = canvas.layout_grids.borrow_mut(); + grids.clear_snap(); + if canvas.snap.get() && f != Point::new(1.0, 1.0) { + let point = pivot + Point::new(ray.x * f.x, ray.y * f.y); + let uniform = modifiers.contains(KeyModifiers::SHIFT); + let direction = (uniform || ray.x == 0.0 || ray.y == 0.0).then_some(ray); + if let Some(snap) = grids.snap( + &editor, + &[point], + 5.0 / canvas.viewport.get().zoom, + &self.selected, + direction, + ) { + let mut corrected = f; + if ray.x != 0.0 { + corrected.x += snap.delta.x / ray.x; + } + if ray.y != 0.0 { + corrected.y += snap.delta.y / ray.y; + } + if uniform { + let factor = if ray.x.abs() >= ray.y.abs() { + corrected.x + } else { + corrected.y + }; + corrected = Point::new(factor, factor); + } + if bounds.width * corrected.x >= bounds.width.min(1.0) + && bounds.height * corrected.y >= bounds.height.min(1.0) + { + f = corrected; + } else { + grids.clear_snap(); + } + } + } + drop(grids); + if session.preview_selection(&mut editor, pivot, f, modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META)) { + self.visible = Rect::new( + pivot.x + (bounds.x - pivot.x) * f.x, + pivot.y + (bounds.y - pivot.y) * f.y, + bounds.width * f.x, + bounds.height * f.y, + ); + } + } + + pub(super) fn cancel(self, canvas: &DesignCanvas) { + if let Some(session) = self.session { + session.cancel(&mut canvas.editor.borrow_mut()); + } + canvas.set_resize_hover(None); + canvas.layout_grids.borrow_mut().clear_snap(); + canvas.invalidate_visual(); + canvas.emit(CanvasEvent::Selection); + } + + pub(super) fn finish(self, canvas: &DesignCanvas) { + canvas.set_resize_hover(None); + if let Some(session) = self.session { + let changed = session.commit(&mut canvas.editor.borrow_mut()); + canvas.emit(if changed { + CanvasEvent::Document + } else { + CanvasEvent::Selection + }); + } else if (!self.painted || self.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::META)) + && self.version == canvas.editor.borrow().render_change_token() + && self.selected == canvas.editor.borrow().selection + && !canvas.cycle_pointer_click(self.origin, self.modifiers) + { + canvas.select_at_pointer(self.origin, self.modifiers, self.clicks, None); + } + } +} diff --git a/crates/ferrodesign-app/src/canvas/selection_tests.rs b/crates/ferrodesign-app/src/canvas/selection_tests.rs index dadb98a..dfbce03 100644 --- a/crates/ferrodesign-app/src/canvas/selection_tests.rs +++ b/crates/ferrodesign-app/src/canvas/selection_tests.rs @@ -275,3 +275,37 @@ fn native_selection_prototype_reuses_one_isolated_index_for_thousand_layer_point assert_eq!(canvas.prototype_selection_index_stats()["indexedNodes"], 1); scope.dispose(); } + +#[test] +fn marquee_preview_switches_hierarchy_at_the_stationary_pointer_and_rejects_stale_releases() { + let mut document = core::Document::new("Marquee preview"); + let frame = layer(&mut document, 1, NodeKind::Frame); + let group = layer(&mut document, frame, NodeKind::Group); + let leaf = layer(&mut document, group, NodeKind::Rectangle); + let locked = layer(&mut document, group, NodeKind::Rectangle); + let hidden = layer(&mut document, frame, NodeKind::Rectangle); + document.nodes.get_mut(&locked).unwrap().locked = true; + document.nodes.get_mut(&hidden).unwrap().visible = false; + let canvas = DesignCanvas::new(Rc::new(RefCell::new(Editor::new(document)))); + let start = Point::new(-10.0, -10.0); + let end = Point::new(110.0, 110.0); + let mut drag = Marquee::new(&canvas.editor.borrow(), start, false); + assert!(drag.update(&canvas, &canvas.editor.borrow(), end, KeyModifiers::empty())); + assert_eq!(drag.preview, [frame]); + *canvas.gesture.borrow_mut() = Gesture::Marquee(drag); + let mut event = KeyEventArgs::new(); + event.key = Key::LWin; + for pressed in [true, false] { + assert!(canvas.marquee_modifier(&event, pressed)); + let gesture = canvas.gesture.borrow(); + let Gesture::Marquee(drag) = &*gesture else { panic!("lost marquee") }; + assert_eq!(drag.preview, [if pressed { leaf } else { frame }]); + assert!(canvas.editor.borrow().selection.is_empty()); + } + let replacement = Editor::new(canvas.editor.borrow().document.clone()); + *canvas.editor.borrow_mut() = replacement; + let mut gesture = canvas.gesture.borrow_mut(); + let Gesture::Marquee(drag) = &mut *gesture else { panic!("lost marquee") }; + assert!(!drag.update(&canvas, &canvas.editor.borrow(), end, KeyModifiers::empty())); + assert!(drag.preview.is_empty()); +} diff --git a/crates/ferrodesign-app/src/canvas/slices.rs b/crates/ferrodesign-app/src/canvas/slices.rs new file mode 100644 index 0000000..6e21003 --- /dev/null +++ b/crates/ferrodesign-app/src/canvas/slices.rs @@ -0,0 +1,33 @@ +//! Export-region guides belong to editor chrome, outside the artwork render pass. +use super::*; + +pub(super) fn draw(ctx: &mut DrawingContext, editor: &Editor, viewport: Viewport) { + use ferroui_base::media::{immutable::{ImmutableDashStyle, ImmutableSolidColorBrush}, PenLineCap, PenLineJoin}; + let ids = editor.scene.slice_regions_in(viewport.visible_rect()); + if ids.is_empty() { return; } + let outline: Rc = Rc::new(ImmutablePen::new( + Some(Rc::new(ImmutableSolidColorBrush::from_uint32(0xFF818B9B))), 1.0, Some(Rc::new(ImmutableDashStyle::new(Some(&[4.0, 3.0]), 0.0))), + PenLineCap::Flat, PenLineJoin::Miter, 10.0, + )); + for id in ids { + let node = &editor.document.nodes[&id]; + let transform = editor.scene.transforms[&id]; + let corners = node.local_bounds().corners().map(|p| viewport.world_to_screen(transform.apply(p))); + for i in 0..4 { + ctx.draw_line(&outline, ui(corners[i]), ui(corners[(i + 1) % 4])); + } + let left = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let right = corners.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max); + let top = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min); + canvas_caption(ctx, &node.name, Point::new(left, top - 16.0), right - left, "#788596"); + } +} + +pub(super) fn pick(editor: &Editor, id: NodeId, world: Point, tolerance: f64) -> bool { + // Let the user select artwork through an empty, unselected slice. Its + // edge or Layers row selects it; its entire interior can then be dragged. + if editor.selection.contains(&id) { return true; } + let node = &editor.document.nodes[&id]; + let corners = node.local_bounds().corners().map(|p| editor.scene.transforms[&id].apply(p)); + (0..4).any(|i| core::distance_to_segment(world, corners[i], corners[(i + 1) % 4]) <= tolerance) +} diff --git a/crates/ferrodesign-app/src/icons.rs b/crates/ferrodesign-app/src/icons.rs index 4203b80..f845796 100644 --- a/crates/ferrodesign-app/src/icons.rs +++ b/crates/ferrodesign-app/src/icons.rs @@ -14,18 +14,23 @@ pub fn brush(hex: &str) -> Rc { pub fn icon(name: &str, color: &str, size: f64) -> Ref { let data = match name { "logo" => "M4,2 H18 V6 H8 V10 H16 V14 H8 V22 H4 Z", + "search" => "M10,3 A7,7 0 1 1 9.99,3 Z M15,15 L21,21", + "scale" => "M3,10 V21 H14 M4,20 L9,15 M12,4 H20 V12 M11,13 L20,4", "pointer" => "M5,2 L20,12 L13,14 L10,21 Z", "frame" => "M7,2 V22 M17,2 V22 M2,7 H22 M2,17 H22", "rect" | "gradient" => "M4,4 H20 V20 H4 Z", "chevron-down" => "M5,8 L12,15 L19,8", "chevron-right" => "M8,5 L15,12 L8,19", "ellipse" => "M12,3 A9,9 0 1 1 11.99,3 Z", + "arrow" => "M4,20 L20,4 M11,4 H20 V13", + "pencil" => "M3,21 L4,15 L16,3 Q17,2 18,3 L21,6 Q22,7 21,8 L9,20 Z M14,5 L19,10 M4,15 L9,20", "line" => "M4,20 L20,4", "pen" => "M3,21 L7,8 L17,3 L21,7 L16,17 Z M3,21 L12,12 M10,10 L14,14", "text" => "M4,5 V3 H20 V5 M12,3 V21 M8,21 H16", "hand" => "M8,13 V6 Q8,3 10,5 V11 V3 Q12,1 13,4 V11 V5 Q16,3 16,6 V12 V8 Q19,6 19,10 V16 Q19,22 13,22 H11 Q8,22 6,18 L3,13 Q3,10 5,12 Z", "comment" => "M4,3 H20 V17 H10 L4,22 Z M8,8 H16 M8,12 H14", "actions" => "M12,2 L15,9 L22,12 L15,15 L12,22 L9,15 L2,12 L9,9 Z", + "more" => "M4,12 A1,1 0 1 0 6,12 A1,1 0 1 0 4,12 Z M11,12 A1,1 0 1 0 13,12 A1,1 0 1 0 11,12 Z M18,12 A1,1 0 1 0 20,12 A1,1 0 1 0 18,12 Z", "file" => "M5,2 H14 L20,8 V22 H5 Z M14,2 V8 H20", "assets" | "component" => "M12,2 L16,6 L12,10 L8,6 Z M6,8 L10,12 L6,16 L2,12 Z M18,8 L22,12 L18,16 L14,12 Z M12,14 L16,18 L12,22 L8,18 Z", "tools" => "M3,3 H9 V9 H3 Z M15,3 H21 V9 H15 Z M3,15 H9 V21 H3 Z M15,15 H21 V21 H15 Z", @@ -34,6 +39,9 @@ pub fn icon(name: &str, color: &str, size: f64) -> Ref { "theme" => "M20,16 A9,9 0 1 1 8,3 A9,9 0 0 0 20,16 Z", "play" => "M6,3 L21,12 L6,21 Z", "eye" => "M2,12 Q12,-1 22,12 Q12,25 2,12 Z M12,8 A4,4 0 1 1 11.99,8 Z", + "eye-off" => "M3,3 L21,21 M9,6 Q16,3 22,12 Q20,15 18,16 M14,18 Q7,20 2,12 L5,9", + "unlock" => "M7,11 V7 A5,5 0 0 1 17,7 M5,11 H19 V22 H5 Z M12,15 V18", + "instance" => "M12,2 L22,12 L12,22 L2,12 Z", "lock" => "M7,11 V7 A5,5 0 0 1 17,7 V11 M5,11 H19 V22 H5 Z M12,15 V18", "align-left" => "M3,2 V22 M7,5 H21 V9 H7 Z M7,15 H16 V19 H7 Z", "align-center" => "M12,2 V22 M3,5 H21 V9 H3 Z M7,15 H17 V19 H7 Z", @@ -44,6 +52,7 @@ pub fn icon(name: &str, color: &str, size: f64) -> Ref { "group" => "M2,8 V2 H8 M16,2 H22 V8 M22,16 V22 H16 M8,22 H2 V16 M7,7 H17 V17 H7 Z", "polygon" => "M12,2 L23,21 H1 Z", "star" => "M12,1 L15,8 L23,9 L17,14 L19,22 L12,18 L5,22 L7,14 L1,9 L9,8 Z", + "slice" => "M3,6 H7 V4 H3 Z M10,4 H14 V6 H10 Z M17,4 H21 V6 H17 Z M20,9 V13 H22 V9 Z M20,16 V20 H17 V22 H22 V16 Z M10,20 H14 V22 H10 Z M3,16 H5 V20 H7 V22 H3 Z M3,9 H5 V13 H3 Z", "section" => "M2,4 H9 L12,7 H22 V21 H2 Z", _ => "M5,12 H19 M12,5 V19", }; diff --git a/crates/ferrodesign-app/src/main.rs b/crates/ferrodesign-app/src/main.rs index 67cdf59..3614bed 100644 --- a/crates/ferrodesign-app/src/main.rs +++ b/crates/ferrodesign-app/src/main.rs @@ -57,6 +57,11 @@ pub fn editor_command(command: &str) -> bool { ferrodesign_app::app::with_shell(|s| s.command(command)).unwrap_or(false) } #[cfg(target_os = "emscripten")] +#[wasm_bindgen(js_name = ferroDesignCancelPointerScrub)] +pub fn cancel_pointer_scrub(token: u32) { + ferrodesign_app::app::with_shell(|s| s.cancel_browser_pointer_scrub(token)); +} +#[cfg(target_os = "emscripten")] #[wasm_bindgen(js_name = ferroDesignExportJson)] pub fn export_document() -> String { ferrodesign_app::app::with_shell(|s| s.document_json()).unwrap_or_default() diff --git a/crates/ferrodesign-app/src/platform.rs b/crates/ferrodesign-app/src/platform.rs index f463c18..8b95ef0 100644 --- a/crates/ferrodesign-app/src/platform.rs +++ b/crates/ferrodesign-app/src/platform.rs @@ -1,4 +1,6 @@ //! Host services only. The editor UI and artwork are rendered by FerroUI. +mod nudge; +pub(crate) use nudge::NudgeAmounts; #[cfg(not(target_os = "emscripten"))] static NATIVE_SAVE_SUCCEEDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); @@ -12,6 +14,12 @@ static NATIVE_DRAFT_DIRTY: std::sync::atomic::AtomicBool = mod browser { use wasm_bindgen::prelude::*; #[wasm_bindgen(inline_js = r#" +export function ferroArmScrub(x, y) { return globalThis.ferroDesignScrubPointer?.arm(x, y) || 0; } +export function ferroRequestScrub(token) { globalThis.ferroDesignScrubPointer?.request(token); } +export function ferroScrubCoordinate(token, axis, absolute) { + return globalThis.ferroDesignScrubPointer?.coordinate(token, axis, absolute) ?? absolute; +} +export function ferroEndScrub(token) { globalThis.ferroDesignScrubPointer?.end(token); } export function ferroLoad() { return globalThis.ferroDesignStorage?.load() || ''; } export function ferroSave(text) { return globalThis.ferroDesignStorage?.save(text) === true; } export function ferroSerializationPending(pending) { globalThis.ferroDesignStorage?.setSerializationPending(pending); } @@ -77,6 +85,14 @@ export function ferroBytes(name, bytes, mime) { } "#)] extern "C" { + #[wasm_bindgen(js_name=ferroArmScrub)] + pub fn arm_scrub(x: f64, y: f64) -> u32; + #[wasm_bindgen(js_name=ferroRequestScrub)] + pub fn request_scrub(token: u32); + #[wasm_bindgen(js_name=ferroScrubCoordinate)] + pub fn scrub_coordinate(token: u32, axis: u32, absolute: f64) -> f64; + #[wasm_bindgen(js_name=ferroEndScrub)] + pub fn end_scrub(token: u32); #[wasm_bindgen(js_name=ferroLoad)] pub fn load() -> String; #[wasm_bindgen(js_name=ferroSave)] @@ -99,12 +115,64 @@ export function ferroBytes(name, bytes, mime) { pub fn bytes(name: &str, bytes: &[u8], mime: &str); } } +/// An optional browser lease. Native backends retain ordinary captured coordinates. +#[derive(Default)] +pub(crate) struct ScrubPointer(u32); + +impl ScrubPointer { + pub fn arm(_origin: ferroui_base::Point) -> Self { + #[cfg(target_os = "emscripten")] + { Self(browser::arm_scrub(_origin.x, _origin.y)) } + #[cfg(not(target_os = "emscripten"))] + { Self::default() } + } + + pub fn matches(&self, token: u32) -> bool { token != 0 && self.0 == token } + + pub fn token(&self) -> u32 { self.0 } + + pub fn request(_token: u32) { + #[cfg(target_os = "emscripten")] + if _token != 0 { browser::request_scrub(_token); } + } + + pub fn point(&self, absolute: ferroui_base::Point) -> ferroui_base::Point { + #[cfg(target_os = "emscripten")] + if self.0 != 0 { + return ferroui_base::Point::new( + browser::scrub_coordinate(self.0, 0, absolute.x), + browser::scrub_coordinate(self.0, 1, absolute.y), + ); + } + absolute + } +} + +impl Drop for ScrubPointer { + fn drop(&mut self) { + #[cfg(target_os = "emscripten")] + if self.0 != 0 { browser::end_scrub(self.0); } + } +} + +// Native interaction tests pump real dispatcher timers, including autosave. +// Keep their sessions thread-local. Atomic filesystem writes have separate +// coverage against disposable paths. +#[cfg(all(test, not(target_os = "emscripten")))] +ferroui_base::ferro_thread_local! { + static TEST_SESSION: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; +} + pub fn load_document() -> String { #[cfg(target_os = "emscripten")] { browser::load() } - #[cfg(not(target_os = "emscripten"))] + #[cfg(all(test, not(target_os = "emscripten")))] + { + TEST_SESSION.with(|session| session.borrow().clone()) + } + #[cfg(all(not(test), not(target_os = "emscripten")))] { std::fs::read_to_string("ferrodesign-autosave.json").unwrap_or_default() } @@ -114,7 +182,12 @@ pub fn save_document(text: &str) -> bool { { browser::save(text) } - #[cfg(not(target_os = "emscripten"))] + #[cfg(all(test, not(target_os = "emscripten")))] + { + TEST_SESSION.with(|session| text.clone_into(&mut session.borrow_mut())); + true + } + #[cfg(all(not(test), not(target_os = "emscripten")))] { let saved = write_document_atomically(std::path::Path::new("ferrodesign-autosave.json"), text) diff --git a/crates/ferrodesign-app/src/platform/nudge.rs b/crates/ferrodesign-app/src/platform/nudge.rs new file mode 100644 index 0000000..cb564e7 --- /dev/null +++ b/crates/ferrodesign-app/src/platform/nudge.rs @@ -0,0 +1,103 @@ +//! The user's nudge increments are independent of the artwork and its history. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct NudgeAmounts { + pub small: f64, + pub big: f64, +} + +impl Default for NudgeAmounts { + fn default() -> Self { + Self { + small: 1.0, + big: 10.0, + } + } +} + +impl NudgeAmounts { + pub fn step(self, big: bool) -> f64 { + if big { + self.big + } else { + self.small + } + } + pub fn valid(value: f64) -> bool { + value > 0.0 && value <= 1e7 + } + + pub fn load() -> Self { + let data = host::load(); + serde_json::from_str::<[f64; 2]>(&data) + .ok() + .filter(|pair| pair.iter().all(|&v| Self::valid(v))) + .map(|[small, big]| Self { small, big }) + .unwrap_or_default() + } + pub fn save(self) -> bool { + Self::valid(self.small) + && Self::valid(self.big) + && host::save( + &serde_json::to_string(&[self.small, self.big]).expect("finite nudge values"), + ) + } +} + +#[cfg(target_os = "emscripten")] +mod host { + use wasm_bindgen::prelude::*; + #[wasm_bindgen(inline_js = r#" +const nudgeKey = "ferrodesign.nudgeAmounts.v1"; +export function loadNudges() { try { return localStorage.getItem(nudgeKey) || ""; } catch { return ""; } } +export function saveNudges(text) { try { localStorage.setItem(nudgeKey, text); return true; } catch { return false; } } +"#)] + extern "C" { + #[wasm_bindgen(js_name=loadNudges)] + pub fn load() -> String; + #[wasm_bindgen(js_name=saveNudges)] + pub fn save(text: &str) -> bool; + } +} + +#[cfg(all(not(test), not(target_os = "emscripten")))] +mod host { + use std::{env, fs, path::PathBuf}; + + fn path() -> Option { + let directory = if cfg!(windows) { + env::var_os("APPDATA").map(PathBuf::from) + } else if cfg!(target_os = "macos") { + env::var_os("HOME").map(|p| PathBuf::from(p).join("Library/Application Support")) + } else { + env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| env::var_os("HOME").map(|p| PathBuf::from(p).join(".config"))) + }?; + Some(directory.join("FerroDesign").join("nudge-amounts.json")) + } + pub fn load() -> String { + path() + .and_then(|p| fs::read_to_string(p).ok()) + .unwrap_or_default() + } + pub fn save(text: &str) -> bool { + let Some(path) = path() else { return false }; + fs::create_dir_all(path.parent().unwrap()).is_ok() + && super::super::write_document_atomically(&path, text).is_ok() + } +} + +#[cfg(all(test, not(target_os = "emscripten")))] +mod host { + ferroui_base::ferro_thread_local! { + static VALUES: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; + } + pub fn load() -> String { + VALUES.with(|v| v.borrow().clone()) + } + pub fn save(text: &str) -> bool { + VALUES.with(|v| text.clone_into(&mut v.borrow_mut())); + true + } +} diff --git a/crates/ferrodesign-app/src/prototype.rs b/crates/ferrodesign-app/src/prototype.rs index 8b05892..9172c04 100644 --- a/crates/ferrodesign-app/src/prototype.rs +++ b/crates/ferrodesign-app/src/prototype.rs @@ -30,6 +30,12 @@ pub enum PrototypeRuntimeEvent { ScrollTo(NodeId), } +/// Keep the same framing policy for initial, instant and animated navigation. +pub(crate) fn fit_preview_frame(viewport: &mut Viewport, bounds: ferrodesign_core::Rect) { + let gutter = (viewport.width.min(viewport.height) * 0.03).clamp(4.0, 32.0); + viewport.fit(bounds, gutter); +} + struct PresentationBefore { frame: Option, current: Option, @@ -45,6 +51,7 @@ struct RunningTransition { } struct AuthoredSnapshot { + start_root: NodeId, document: Document, selection: Vec, active_page: NodeId, @@ -97,6 +104,23 @@ impl PrototypeRuntime { self.player.borrow().current } + pub fn restart(self: &Rc) -> bool { + let root = self.authored.borrow().as_ref().map(|state| state.start_root); + root.is_some_and(|root| self.start(root)) + } + + pub fn can_step(&self, forward: bool) -> bool { + self.player.borrow().can_step(&self.editor.borrow().document, forward) + } + + pub fn has_overlay(&self) -> bool { + !self.player.borrow().overlays.is_empty() + } + + pub fn visible_frame(&self) -> Option { + self.player.borrow().interaction_root() + } + pub fn is_animating(&self) -> bool { self.animation.borrow().is_some() } @@ -165,11 +189,15 @@ impl PrototypeRuntime { self.generation.set(self.generation.get().wrapping_add(1)); { let mut editor = self.editor.borrow_mut(); - if self.authored.borrow().is_none() { + let mut authored = self.authored.borrow_mut(); + if let Some(state) = authored.as_mut() { + state.start_root = root; + } else { // Finish an existing pointer transaction before capturing its // final authored state; preview actions never enter history. editor.commit_transaction(); - *self.authored.borrow_mut() = Some(AuthoredSnapshot { + *authored = Some(AuthoredSnapshot { + start_root: root, document: editor.document.clone(), selection: editor.selection.clone(), active_page: editor.active_page, @@ -224,15 +252,15 @@ impl PrototypeRuntime { true } - pub fn back(self: &Rc) -> bool { - if !self.is_active() { + pub fn step(self: &Rc, forward: bool) -> bool { + if !self.can_step(forward) { return false; } let previous = self.before_action(); let changed = self .player .borrow_mut() - .back(&self.editor.borrow().document); + .step(&mut self.editor.borrow_mut().document, forward); if changed { self.after_action(previous); } @@ -365,19 +393,14 @@ impl PrototypeRuntime { if !self.is_active() { return false; } + let matches = self.player.borrow().key_sources(&self.editor.borrow().document, key); + // Viewer shortcuts and typing unassigned keys need no transition snapshot. + if matches.is_empty() { return false } let previous = self.before_action(); let changed = { let mut editor = self.editor.borrow_mut(); let mut player = self.player.borrow_mut(); - let Some(root) = player.interaction_root() else { - return false; - }; - editor - .document - .descendants(root) - .into_iter() - .rev() - .any(|node| player.trigger_key(&mut editor.document, node, key)) + matches.into_iter().any(|node| player.trigger_key(&mut editor.document, node, key)) }; if changed { self.after_action(previous); @@ -511,7 +534,7 @@ impl PrototypeRuntime { from_viewport.pan = from_viewport.pan + from.origin * from_viewport.zoom; let mut to_viewport = from_viewport; if fit_destination { - to_viewport.fit(to.bounds(), 65.0); + fit_preview_frame(&mut to_viewport, to.bounds()); } let mut final_viewport = to_viewport; final_viewport.pan = final_viewport.pan - to.origin * final_viewport.zoom; diff --git a/crates/ferrodesign-app/src/renderer.rs b/crates/ferrodesign-app/src/renderer.rs index bb5d904..bb88510 100644 --- a/crates/ferrodesign-app/src/renderer.rs +++ b/crates/ferrodesign-app/src/renderer.rs @@ -16,6 +16,7 @@ mod incremental; mod mask_tests; mod masks; mod picking; +pub(crate) mod outlines; mod root_index; mod regions; #[cfg(all(test, not(target_os = "emscripten")))] @@ -114,6 +115,57 @@ struct CachedStroke { arrow: Option>, } +impl CachedStroke { + fn prepare(node: &Node, stroke: &Stroke, geometry: &Ref, stroke_geometry: &Ref, + immutable_brush: Rc) -> Option { + let brush: Rc = immutable_brush.clone(); + let aligned = stroke.align != StrokeAlign::Center && is_closed(node); + let pen = make_pen(stroke, immutable_brush, if aligned { 2.0 } else { 1.0 }); + let mut stroke_clip = None; + let outline = if !stroke.width_profile.is_uniform() { + match ferrodesign_core::variable_width_outline(node, stroke) { + Ok(path) => { + let band = path_geometry(&path); + if aligned { + stroke_clip = Some(if stroke.align == StrokeAlign::Inside { geometry.clone() } + else { combined(GeometryCombineMode::Exclude, + RectangleGeometry::with_rect(band.bounds().inflate(1.0)).upcast(), geometry.clone()) }); + } + Some(band) + } + Err(_) => return None, + } + } else if aligned { + let widened = stroke_geometry.get_widened_geometry(pen.as_ref()); + Some(combined( + if stroke.align == StrokeAlign::Inside { + GeometryCombineMode::Intersect + } else { + GeometryCombineMode::Exclude + }, + widened, + geometry.clone(), + )) + } else { + None + }; + let arrow = if stroke.cap == StrokeCap::Arrow && !is_closed(node) { + arrow_geometry(node, stroke.width) + } else { + None + }; + Some(Self { pen, brush, outline, clip: stroke_clip, arrow }) + } + + fn band(&self, path: &Ref) -> Ref { + let band = self.outline.clone().unwrap_or_else(|| path.get_widened_geometry(self.pen.as_ref())); + match &self.clip { + Some(clip) => combined(GeometryCombineMode::Intersect, band, clip.clone()), + None => band, + } + } +} + struct CachedShadow { geometry: Ref, brush: Rc, @@ -541,7 +593,7 @@ impl ScenePainter { self.prepare_invalid_nodes += 1; return None; }; - if !node.visible || (node.opacity <= 0.0 && !node.is_mask) || self.editing_text == Some(id) + if node.kind == NodeKind::Slice || !node.visible || (node.opacity <= 0.0 && !node.is_mask) || self.editing_text == Some(id) { return None; } @@ -1051,42 +1103,11 @@ fn build_visual( { let (immutable_brush, missing) = make_brush(&stroke.paint, stroke.opacity, images); missing_images += usize::from(missing); - let brush: Rc = immutable_brush.clone(); - let aligned = stroke.align != StrokeAlign::Center && is_closed(node); - let pen = make_pen(stroke, immutable_brush, if aligned { 2.0 } else { 1.0 }); - let mut stroke_clip = None; - let outline = if !stroke.width_profile.is_uniform() { - match ferrodesign_core::variable_width_outline(node, stroke) { - Ok(path) => { - let band = path_geometry(&path); - if aligned { - stroke_clip = Some(if stroke.align == StrokeAlign::Inside { geometry.clone() } - else { combined(GeometryCombineMode::Exclude, - RectangleGeometry::with_rect(band.bounds().inflate(1.0)).upcast(), geometry.clone()) }); - } - Some(band) - } - Err(_) => { unsupported_effects += 1; continue; } - } - } else if aligned { - let widened = stroke_geometry.get_widened_geometry(pen.as_ref()); - Some(combined( - if stroke.align == StrokeAlign::Inside { - GeometryCombineMode::Intersect - } else { - GeometryCombineMode::Exclude - }, - widened, - geometry.clone(), - )) - } else { - None - }; - let arrow = if stroke.cap == StrokeCap::Arrow && !is_closed(node) { - arrow_geometry(node, stroke.width) - } else { - None + let Some(cached) = CachedStroke::prepare(node, stroke, &geometry, &stroke_geometry, immutable_brush) else { + unsupported_effects += 1; + continue; }; + let CachedStroke { outline, pen, arrow, .. } = &cached; let stroke_bounds = outline.as_ref().map(|outline| core_rect(outline.bounds())) .unwrap_or_else(|| core_rect(stroke_geometry.get_render_bounds(pen.as_ref()))); if stroke_bounds.is_finite() { @@ -1096,24 +1117,12 @@ fn build_visual( local_bounds = local_bounds.union(core_rect(arrow.bounds())); } if needs_outline && masks::paint_has_alpha(&stroke.paint) { - let stroke_region = outline - .clone() - .unwrap_or_else(|| stroke_geometry.get_widened_geometry(pen.as_ref())); - let stroke_region = if let Some(clip) = &stroke_clip { - combined(GeometryCombineMode::Intersect, stroke_region, clip.clone()) - } else { stroke_region }; - vector_mask = combined(GeometryCombineMode::Union, vector_mask, stroke_region); - if let Some(arrow) = &arrow { + vector_mask = combined(GeometryCombineMode::Union, vector_mask, cached.band(&stroke_geometry)); + if let Some(arrow) = arrow { vector_mask = combined(GeometryCombineMode::Union, vector_mask, arrow.clone()); } } - strokes.push(CachedStroke { - pen, - brush, - outline, - clip: stroke_clip, - arrow, - }); + strokes.push(cached); } if node.kind.is_container() && node.kind != NodeKind::Boolean && needs_outline { @@ -2017,7 +2026,7 @@ fn core_rect(rect: UiRect) -> Rect { Rect::new(rect.x, rect.y, rect.width, rect.height) } -fn ui_matrix(transform: Transform) -> Matrix { +pub(crate) fn ui_matrix(transform: Transform) -> Matrix { Matrix::new( transform.a, transform.b, diff --git a/crates/ferrodesign-app/src/renderer/incremental.rs b/crates/ferrodesign-app/src/renderer/incremental.rs index ef932be..b3d32a5 100644 --- a/crates/ferrodesign-app/src/renderer/incremental.rs +++ b/crates/ferrodesign-app/src/renderer/incremental.rs @@ -77,6 +77,7 @@ impl ScenePainter { // Hidden nodes, external ancestors of exported roots, and unusual // overlapping root lists deliberately retain full preparation. let Some(&index) = self.prepared_indices.get(id) else { + if node.kind == NodeKind::Slice { continue; } // Entering/leaving inline editing invalidates the scene. With // that state and the editor ledger still continuous, this // exact leaf remains absent from preparation because the diff --git a/crates/ferrodesign-app/src/renderer/outlines.rs b/crates/ferrodesign-app/src/renderer/outlines.rs new file mode 100644 index 0000000..da67ce1 --- /dev/null +++ b/crates/ferrodesign-app/src/renderer/outlines.rs @@ -0,0 +1,300 @@ +//! A paint-independent view of editable paths. Its spatial index includes +//! Boolean operands, clipped children and hidden layers. Camera changes only +//! query the index; scoped document edits invalidate the affected entries. +use super::*; +use ferrodesign_core::{SpatialIndex, MAX_DOCUMENT_DEPTH}; + +#[derive(Default)] +pub(crate) struct OutlineScene { + token: Option, + page: Option, + spatial: SpatialIndex, + entries: HashMap, +} + +struct Entry { + order: usize, + world: Transform, + hidden: bool, + operand: bool, + paths: Option, +} + +struct Paths { + edges: Vec>, + bounds: Ref, +} + +fn placed(path: Ref, transform: Transform) -> Ref { + if transform == Transform::IDENTITY { + return path; + } + // Widened paths can be immutable. Transform the collection, leaving the + // original geometry and its fill rule available to other operations. + let group = GeometryGroup::new(); + group.children().add(path); + group.set_transform(MatrixTransform::with_matrix(ui_matrix(transform))); + group.upcast() +} + +fn silhouette(document: &Document, node: &Node, depth: usize) -> Ref { + match node.kind { + NodeKind::Text => { + let text = make_text_layout(node, solid_brush(Color::BLACK, 1.)); + text_outline(&text.layout, text.origin) + } + NodeKind::Boolean | NodeKind::Group => { + let mode = if node.kind == NodeKind::Boolean { + boolean_mode(node.boolean_operation) + } else { + GeometryCombineMode::Union + }; + let result = (depth < MAX_DOCUMENT_DEPTH) + .then(|| { + node.children + .iter() + .filter_map(|id| document.nodes.get(id)) + .filter(|child| child.visible) + .map(|child| { + placed( + silhouette(document, child, depth + 1), + child.local_transform(), + ) + }) + .reduce(|a, b| combined(mode, a, b)) + }) + .flatten(); + result.unwrap_or_else(|| StreamGeometry::new().upcast()) + } + _ => shape_geometry(node), + } +} + +impl Paths { + fn build(document: &Document, node: &Node, world: Transform) -> Self { + let bounds = placed( + RectangleGeometry::with_rect(ui_rect(node.local_bounds())).upcast(), + world, + ); + let visible_strokes = || { + node.strokes + .iter() + .filter(|s| s.visible && s.width > 0. && s.opacity > 0.) + }; + let mut edges = Vec::new(); + if node.kind != NodeKind::Group || visible_strokes().next().is_some() { + let body = silhouette(document, node, 0); + let path = node + .vector_network + .as_ref() + .map(|n| path_geometry(&n.stroke_path())) + .unwrap_or_else(|| body.clone()); + edges.push(placed(path.clone(), world)); + let ink = Rc::new(ImmutableSolidColorBrush::from_uint32(0xFF000000)); + for stroke in visible_strokes() { + if let Some(s) = CachedStroke::prepare(node, stroke, &body, &path, ink.clone()) { + edges.push(placed(s.band(&path), world)); + if let Some(arrow) = s.arrow { + edges.push(placed(arrow, world)); + } + } + } + } + Self { bounds, edges } + } +} + +impl OutlineScene { + fn index(&mut self, editor: &Editor, id: NodeId, order: usize) { + self.spatial.remove(id); + self.entries.remove(&id); + let Some(node) = editor.document.nodes.get(&id) else { + return; + }; + let Some(&world) = editor.scene.transforms.get(&id) else { + return; + }; + + let mut hidden = false; + let mut operand = false; + let mut current = Some(node); + while let Some(n) = current { + hidden |= !n.visible; + operand |= n.id != id && n.kind == NodeKind::Boolean; + current = n.parent.and_then(|p| editor.document.nodes.get(&p)); + } + // Text can overflow its authored box, as can path control points, + // widened strokes and Boolean operands. Index their exact extents. + let exact = matches!( + node.kind, + NodeKind::Text | NodeKind::Vector | NodeKind::Boolean + ) || node.strokes.iter().any(|s| s.visible && s.width > 0.); + let paths = exact.then(|| Paths::build(&editor.document, node, world)); + let mut extent = world.transform_rect(node.local_bounds()); + if let Some(paths) = &paths { + for p in &paths.edges { + extent = extent.union(core_rect(p.bounds())); + } + } + self.spatial.insert(id, extent); + self.entries.insert( + id, + Entry { + order, + world, + hidden, + operand, + paths, + }, + ); + } + + fn prepare(&mut self, editor: &Editor) { + let token = editor.render_change_token(); + if self.page == Some(editor.active_page) && self.token.as_ref() == Some(&token) { + return; + } + let changes = self + .token + .as_ref() + .filter(|_| self.page == Some(editor.active_page)) + .map(|t| editor.render_changes_since(t)) + .unwrap_or(EditorRenderChanges::Full); + match changes { + EditorRenderChanges::Full => { + self.spatial.clear(); + self.entries.clear(); + for (order, id) in editor + .document + .draw_order(editor.active_page) + .into_iter() + .enumerate() + { + self.index(editor, id, order); + } + } + EditorRenderChanges::Nodes { nodes, ancestors } => { + for id in nodes.into_iter().chain(ancestors) { + if let Some(entry) = self.entries.get(&id) { + self.index(editor, id, entry.order); + } + } + } + } + self.page = Some(editor.active_page); + self.token = Some(token); + } + + fn candidates(&mut self, editor: &Editor, viewport: Rect, hidden: bool) -> Vec { + self.prepare(editor); + let mut ids = self.spatial.query(viewport); + ids.retain(|id| hidden || !self.entries[id].hidden); + ids.sort_unstable_by_key(|id| self.entries[id].order); + ids + } + + pub(crate) fn render( + &mut self, + ctx: &mut DrawingContext, + editor: &Editor, + viewport: Rect, + zoom: f64, + hidden: bool, + bounds: bool, + dark: bool, + roots: &[NodeId], + ) { + let ink = |color| Rc::new(ImmutablePen::from_uint32(color, 1. / zoom)) as Rc; + let normal = ink(if dark { 0xFFE2E2E2 } else { 0xFF555555 }); + let secondary = ink(if dark { 0xFFAAAAAA } else { 0xFF9D9D9D }); + let dashed: Rc = Rc::new(ImmutablePen::new( + Some(Rc::new(ImmutableSolidColorBrush::from_uint32(if dark { + 0xFFAAAAAA + } else { + 0xFF888888 + }))), + 1. / zoom, + Some(Rc::new(ImmutableDashStyle::new(Some(&[4., 3.]), 0.))), + PenLineCap::Flat, + PenLineJoin::Miter, + 10., + )); + let selected: HashSet<_> = roots.iter().copied().collect(); + for id in self.candidates(editor, viewport.inflate(1. / zoom), hidden) { + if editor.document.nodes[&id].kind == NodeKind::Slice { + continue; + } + if !selected.is_empty() { + let mut current = Some(id); + while let Some(parent) = current { + if selected.contains(&parent) { + break; + } + current = editor.document.nodes.get(&parent).and_then(|n| n.parent); + } + if current.is_none() { + continue; + } + } + let entry = self.entries.get_mut(&id).unwrap(); + let paths = entry.paths.get_or_insert_with(|| { + Paths::build(&editor.document, &editor.document.nodes[&id], entry.world) + }); + if bounds { + ctx.draw_geometry(None, Some(&dashed), &paths.bounds); + } + for p in &paths.edges { + ctx.draw_geometry( + None, + Some(if entry.operand || entry.hidden { + &secondary + } else { + &normal + }), + p, + ); + } + } + } + + pub(crate) fn enclosed(&mut self, editor: &Editor, rect: Rect, hidden: bool) -> Vec { + self.candidates(editor, rect, hidden) + .into_iter() + .filter(|id| { + !editor.document.effective_locked(*id) + && self + .spatial + .bounds(*id) + .is_some_and(|b| rect.contains_rect(b)) + }) + .collect() + } + + pub(crate) fn hits( + &mut self, + editor: &Editor, + point: Point, + tolerance: f64, + hidden: bool, + bounds: bool, + include_locked: bool, + ) -> Vec { + let envelope = Rect::new(point.x, point.y, 0., 0.).inflate(tolerance); + let pen = ImmutablePen::from_uint32(0xFF000000, 2. * tolerance); + self.candidates(editor, envelope, hidden) + .into_iter() + .rev() + .filter(|id| { + if !include_locked && editor.document.effective_locked(*id) { + return false; + } + let entry = self.entries.get_mut(id).unwrap(); + let paths = entry.paths.get_or_insert_with(|| { + Paths::build(&editor.document, &editor.document.nodes[id], entry.world) + }); + let hit = |p: &Ref| p.stroke_contains(&pen, ui_point(point)); + (bounds && hit(&paths.bounds)) || paths.edges.iter().any(hit) + }) + .collect() + } +} diff --git a/crates/ferrodesign-app/src/shell.rs b/crates/ferrodesign-app/src/shell.rs index 0e2d192..4df9b99 100644 --- a/crates/ferrodesign-app/src/shell.rs +++ b/crates/ferrodesign-app/src/shell.rs @@ -1,9 +1,12 @@ //! The XAML shell binds real FerroUI controls to the document command layer. +mod actions; mod paints; mod fields; mod diagnostics; mod panels; mod geometry_authoring; +mod geometry_fields; +mod geometry_scrub; mod selection; mod styles; mod effects; @@ -11,13 +14,26 @@ mod compositing; mod components; mod layout; mod layer_projection; +mod layer_navigation; +mod layer_drag; +mod layer_rename; +mod bulk_rename; +mod pages; mod prototyping; +mod playback; mod rich_text; mod text; mod variables; +mod zoom; +mod ruler_guides; +mod nudge; +mod toolbar; +mod scale; +mod workspace; mod vector_export; mod text_export; mod stroke_profiles; +mod slice_export; use crate::{ canvas::{CanvasEvent, DesignCanvas, Tool}, icons, platform, @@ -61,14 +77,25 @@ pub struct EditorShell { inline_layout: RefCell>, inline_updating: Cell, inline_draft_dirty: Cell, + geometry_readouts: geometry_fields::GeometryReadouts, + geometry_scrub: RefCell>, inspector_drafts: RefCell>>, panel_state: panels::PanelState, + nudge_form: RefCell>, expanded: RefCell>, layer_buttons: RefCell>>, layer_projection: RefCell, - page_rows: RefCell>, + layer_drag: RefCell, + page_drag: RefCell>, + page_list: RefCell, assets: Cell, inspector_tab: Cell, + displayed_zoom: Cell>, + toolbar: toolbar::ToolbarState, + scale_anchor: Cell, + compact: Cell, + compact_panel: Cell, + workspace_ui: workspace::WorkspaceUi, prototype_targets: RefCell>, prototype: Rc, pending_save: Cell, @@ -121,14 +148,25 @@ impl EditorShell { inline_layout: RefCell::new(None), inline_updating: Cell::new(false), inline_draft_dirty: Cell::new(false), + geometry_readouts: Default::default(), + geometry_scrub: RefCell::new(None), inspector_drafts: RefCell::new(Vec::new()), panel_state: panels::PanelState::default(), + nudge_form: RefCell::new(None), expanded: RefCell::new(expanded), layer_buttons: RefCell::new(BTreeMap::new()), layer_projection: RefCell::new(LayerProjection::default()), - page_rows: RefCell::new(Vec::new()), + layer_drag: RefCell::default(), + page_drag: RefCell::default(), + page_list: RefCell::default(), assets: Cell::new(false), inspector_tab: Cell::new(0), + displayed_zoom: Cell::new(None), + toolbar: toolbar::ToolbarState::default(), + scale_anchor: Cell::new(4), + compact: Cell::new(false), + compact_panel: Cell::default(), + workspace_ui: Default::default(), prototype_targets: RefCell::new(Vec::new()), prototype, pending_save: Cell::new(false), @@ -215,8 +253,12 @@ impl EditorShell { }); } fn wire(self: &Rc) { + self.wire_action_dialog(); + self.wire_nudge_backdrop(); self.wire_inline_text(); + self.wire_zoom(); self.wire_panels(); + self.wire_layer_drag(); self.wire_geometry_authoring(); self.wire_stroke_profiles(); self.hook("EditPaintsButton", "paints"); @@ -384,6 +426,7 @@ impl EditorShell { ); for (name, icon) in [ ("MenuButton", "logo"), + ("PageActionsButton", "more"), ("FileRailButton", "file"), ("AssetsRailButton", "assets"), ("ToolsRailButton", "tools"), @@ -395,14 +438,6 @@ impl EditorShell { ("VisibleButton", "eye"), ("LockedButton", "lock"), ("GradientButton", "gradient"), - ("SelectTool", "pointer"), - ("FrameTool", "frame"), - ("RectTool", "rect"), - ("EllipseTool", "ellipse"), - ("PenTool", "pen"), - ("TextTool", "text"), - ("HandTool", "hand"), - ("CommentTool", "comment"), ("ActionsButton", "actions"), ("AlignLeftButton", "align-left"), ("AlignCenterButton", "align-center"), @@ -414,6 +449,12 @@ impl EditorShell { ] { self.icon(name, icon); } + self.wire_toolbar(); + self.wire_playback_focus(); + self.wire_scale(); + self.wire_workspace(); + self.wire_geometry_scrub_keys(); + self.wire_page_menu(); for (name, command) in [ ("MenuButton", "menu"), ("FileActionsButton", "menu"), @@ -425,14 +466,12 @@ impl EditorShell { ("HistoryRailButton", "history"), ("ThemeButton", "theme"), ("PresentButton", "present"), + ("PreviewBack", "prototype-back"), + ("PreviewNext", "prototype-next"), + ("PreviewRestart", "prototype-restart"), + ("PreviewExit", "prototype-exit"), ("ShareButton", "share"), - ("SelectTool", "select"), - ("FrameTool", "frame"), - ("RectTool", "rectangle"), - ("EllipseTool", "ellipse"), - ("PenTool", "pen"), ("TextTool", "text"), - ("HandTool", "hand"), ("CommentTool", "comment"), ("ActionsButton", "actions"), ("DesignTab", "design-tab"), @@ -481,7 +520,6 @@ impl EditorShell { ("ExportSvgButton", "export-svg"), ("ExportPngButton", "export-png"), ("ZoomOutButton", "zoom-out"), - ("ZoomValueButton", "fit"), ("ZoomInButton", "zoom-in"), ("CloseModalButton", "close-modal"), ("EditVectorButton", "edit-vector"), @@ -514,8 +552,42 @@ impl EditorShell { s.canvas_event(event) } }); + let search = self.field("LayerSearch"); + let weak = Rc::downgrade(self); + self.button("ClearLayerSearch").click(move |_, _| { + if let Some(shell) = weak.upgrade() { + let search = shell.field("LayerSearch"); + search.clear(); + search.focus(); + } + }); + let weak = Rc::downgrade(self); + search.add_handler_with(InputElement::key_down_event(), move |_, event| { + if event.key == Key::Down && event.key_modifiers.is_empty() { + if let Some(shell) = weak.upgrade() { + if !shell.field("LayerSearch").is_composing_text() && shell.enter_layer_rows() { + event.set_handled(true); + } + } + } + }, ferroui_base::interactivity::RoutingStrategies::TUNNEL, false); + let weak = Rc::downgrade(self); + search.add_handler(InputElement::key_down_event(), move |_, event| { + if event.key != Key::Escape || !event.key_modifiers.is_empty() { return; } + let Some(shell) = weak.upgrade() else { return; }; + let search = shell.field("LayerSearch"); + if search.is_composing_text() { return; } + if !search.text().unwrap_or_default().is_empty() { + search.clear(); + } else if shell.compact.get() { + return; // Let the drawer handle the next Escape. + } else { + shell.canvas.focus(); + } + event.set_handled(true); + }); let weak = Rc::downgrade(self); - self.field("LayerSearch").text_changed(move |_, _| { + search.text_changed(move |_, _| { if let Some(s) = weak.upgrade() { if !s.updating.get() { s.refresh_layers(); @@ -524,6 +596,7 @@ impl EditorShell { }); for name in [ "NameField", + "ScaleFactorField", "ScaleWidthField", "ScaleHeightField", "XField", "YField", "RotationField", @@ -602,6 +675,7 @@ impl EditorShell { fn canvas_event(self: &Rc, event: CanvasEvent) { match event { CanvasEvent::Document => self.changed(), + CanvasEvent::ResizePreview => self.queue_resize_readouts(), CanvasEvent::Draft => { self.sync_document_draft_dirty(); // A no-op stroke still needs to resume a previous edit's @@ -617,7 +691,7 @@ impl EditorShell { self.sync_zoom(); self.position_inline_text(); } - CanvasEvent::Tool => self.sync_tools(), + CanvasEvent::Tool => self.refresh_selection(), CanvasEvent::Notice(message) => self.toast(&message), CanvasEvent::EditText(id) => { self.begin_inline_text(id); @@ -635,11 +709,9 @@ impl EditorShell { CanvasEvent::PrototypePointerMoved(point) => { self.prototype.pointer_moved(point); } CanvasEvent::PrototypePointerExited => { self.prototype.pointer_exited(); } CanvasEvent::PrototypeKey(key) => { - if !self.prototype.key(&key) && key == "Escape" { - self.prototype.stop(); - } + self.preview_canvas_key(&key); } - CanvasEvent::ExitPresentation => self.set_presentation(None), + CanvasEvent::ExitPresentation => { self.set_presentation(None); } CanvasEvent::Action(command) => { self.command(command); } @@ -705,24 +777,27 @@ impl EditorShell { state }; let mut chrome: serde_json::Map = [ - "CanvasHost", - "Toolbar", + "CanvasHost", "MinimizeUiButton", "RestoreUiButton", "MinimizedInspectorButton", + "NavigationRail", "RightPanel", "LeftPanel", + "Toolbar", "CompactLayers", "CompactProperties", "SelectTool", "FrameTool", "RectTool", - "EllipseTool", + "RegionToolMenu", "ShapeToolMenu", "DrawingToolMenu", "PenTool", "TextTool", - "HandTool", + "PreviewControls", "PreviewBack", "PreviewNext", "PreviewRestart", "PreviewExit", "PreviewFrameTitle", + "MoveToolMenu", "ScaleFactorField", "ScaleWidthField", "ScaleHeightField", "CommentTool", "ActionsButton", "PresentButton", "PagesViewport", - "LayersViewport", + "LayersViewport", "LayerSearch", "ClearLayerSearch", "CollapseLayersButton", "EmptyLayers", "InspectorViewport", "DesignTab", "PrototypeTab", "InspectTab", "PrototypeInspector", "InspectInspector", "PageInspector", "DesignInspector", "PageBackgroundField", "PageBackgroundSwatch", "NameField", "WidthField", "HeightField", "FillField", "TextField", + "XScrub", "YScrub", "WidthScrub", "HeightScrub", "RotationScrub", "RadiusField", "CornerSmoothingField", "IndependentCornersButton", "StrokeProfileChoice", "StrokeWidthCustomButton", "StrokeWidthHandlesButton", "StrokeWidthField", "DashField", "SelectMatchingButton", @@ -732,7 +807,7 @@ impl EditorShell { "PageStylesButton", "PageVariablesButton", "PageComponentsButton", "VariablesRailButton", "EditLayoutButton", "PaddingField", "GapField", "CrossGapField", - "AddPageButton", + "AddPageButton", "PageActionsButton", "EditVectorButton", "DoneVectorButton", "VectorPointOptions", @@ -766,6 +841,31 @@ impl EditorShell { ) }) .collect(); + // The diagnostics bridge exposes just the realized rows used by the native tree. + let host = self.control::("LayersHost"); + let projection = self.layer_projection.borrow(); + for container in host.get_realized_containers() { + let index = host.index_from_container(&container); + if let Some(row) = usize::try_from(index).ok().and_then(|i| projection.rows().get(i)) { + if let Some(button) = self.layer_buttons.borrow().get(&row.id).and_then(WeakRef::upgrade) { + chrome.insert(format!("LayerRow{}", row.id), native_bounds(&button)); + } + } + } + drop(projection); + let mut popup_items = self.open_tool_items(); + if self.button("PageActionsButton").flyout().is_some_and(|f| f.is_open()) { + popup_items.extend(["PageRename", "PageDuplicate", "PageMoveUp", "PageMoveDown", "PageDelete"].map(|name| (name, self.control(name)))); + } + for (name, item) in popup_items { + if item.is_attached_to_visual_tree() { + let mut bounds = native_bounds(&item); + let origin = self.root.point_to_client(item.point_to_screen(ferroui_base::Point::default())); + bounds["x"] = json!(origin.x); + bounds["y"] = json!(origin.y); + chrome.insert(name.into(), bounds); + } + } if self.control::("ModalBackdrop").is_visible() { for visual in self .control::("ModalBackdrop") @@ -774,7 +874,7 @@ impl EditorShell { if let Some(control) = visual.cast::() { if let Some(name) = control .name() - .filter(|name| ["Paint", "Effect", "Interaction", "RichText", "Paragraph", "StrokeWidth", "StrokeProfile", "Variable", "Guide", "Component", "Compositing", "Crop", "Layout", "Action", "SelectLayer", "SelectSame", "Affine", "FramePreset", "Style"] + .filter(|name| ["Paint", "Effect", "Interaction", "RichText", "Paragraph", "StrokeWidth", "StrokeProfile", "Variable", "Guide", "Component", "Compositing", "Crop", "Layout", "Action", "SelectLayer", "SelectSame", "Affine", "FramePreset", "Style", "Rename", "Ruler", "Nudge"] .iter().any(|prefix| name.starts_with(prefix))) { chrome.insert(name, native_bounds(&control)); @@ -793,6 +893,13 @@ impl EditorShell { .children() .get(index); chrome.insert("ActivePage".into(), native_bounds(&button)); + if let Some(row) = button.cast::() { + let children = row.children(); + if let Some(field) = (1..children.count()).map(|i| children.get(i)) + .find(|c| c.name().as_deref() == Some("PageRenameEditor")) { + chrome.insert("PageRenameEditor".into(), native_bounds(&field)); + } + } } // Keep each macro expansion small while preserving the flat bridge // schema. These groups are moved into the result without cloning values. @@ -801,12 +908,13 @@ impl EditorShell { for group in [json!({ "revision":editor.revision,"documentRevision":editor.document.revision,"nodeCount":editor.document.nodes.len(), "selectionCount":editor.selection.len(),"selection":editor.selection,"selectionBounds":selection_bounds, - "tool":self.canvas.tool.get().name(),"zoom":v.zoom,"pan":{"x":v.pan.x,"y":v.pan.y}, - "selectGroups":self.canvas.select_groups.get(), + "tool":self.canvas.tool.get().name(),"rulersVisible": self.canvas.show_rulers.get(), "outlineView": {"enabled":self.canvas.show_outlines.get(), + "includeHidden": self.canvas.outline_hidden.get(), "objectBounds":self.canvas.outline_bounds.get()}, "zoom":v.zoom,"pan":{"x":v.pan.x,"y":v.pan.y}, + "selectGroups":self.canvas.select_groups.get(),"measurementTarget":self.canvas.measurement_target(), "prototypeSelectionIndex":self.canvas.prototype_selection_index_stats(), "zoomLabel":self.button("ZoomValueButton").content().and_then(|value| value.downcast_ref::().cloned()), "canvas":{"x":canvas.x,"y":canvas.y,"width":canvas.width,"height":canvas.height}, - "chrome":chrome, + "chrome":chrome,"workspaceUi":self.workspace_ui.name(), "layerRowCount":layer_row_count, "realizedLayerRows":self.layer_buttons.borrow().values().filter(|row| row.upgrade().is_some()).count(), "vectorNode":self.canvas.vector_node.get(), @@ -863,6 +971,7 @@ impl EditorShell { self.toast(message); } pub fn import_document(self: &Rc, text: &str, name: &str) -> bool { + self.finish_geometry_scrub(false); self.finish_inline_text(true); if !self.flush_inspector_drafts() { self.toast("Correct the inspector value or press Escape before opening a design."); @@ -883,9 +992,6 @@ impl EditorShell { match result { Ok(_) => { self.expanded.borrow_mut().clear(); - if !is_svg { - self.canvas.reset_fit(); - } self.canvas.invalidate_scene(); self.changed(); self.close_modal(); @@ -978,7 +1084,7 @@ impl EditorShell { // A background timer may belong to the previous committed edit. The // held bucket preview must remain cancelable; release/cancel schedules // a fresh save, while explicit lifecycle boundaries finish it first. - if self.canvas.network_bucket_state().1 || self.canvas.stroke_width_active() + if self.geometry_scrub_active() || self.canvas.network_bucket_state().1 || self.canvas.stroke_width_active() || self.canvas.corner_radius_active() || self.canvas.linear_gradient_active() { return; } if let Some(timer) = self.pending_save_timer.borrow_mut().take() { timer.dispose(); } if !self.pending_save.get() { return; } @@ -998,27 +1104,8 @@ impl EditorShell { platform::set_document_serialization_pending(false); self.text("DocumentStatus", &platform::document_save_status()); } - fn sync_zoom(&self) { - self.button("ZoomValueButton") - .set_content(Some(Rc::new(format!( - "{}%", - (self.canvas.viewport.get().zoom * 100.0).round() - )))); - } fn sync_tools(&self) { - let active = self.canvas.tool.get(); - for (name, tool) in [ - ("SelectTool", Tool::Select), - ("FrameTool", Tool::Frame), - ("RectTool", Tool::Rectangle), - ("EllipseTool", Tool::Ellipse), - ("PenTool", Tool::Pen), - ("TextTool", Tool::Text), - ("HandTool", Tool::Hand), - ("CommentTool", Tool::Comment), - ] { - self.button(name).classes().set("active", active == tool); - } + self.sync_toolbar(); self.sync_zoom(); } fn refresh(self: &Rc) { @@ -1032,6 +1119,7 @@ impl EditorShell { self.refresh_layers(); self.refresh_inspector(); self.updating.set(false); + self.sync_context_inspector(); self.refresh_open_panel(); } fn refresh_selection(self: &Rc) { @@ -1042,9 +1130,12 @@ impl EditorShell { // clicking artwork never recreates every page, icon and layer control. self.canvas.reconcile_image_crop(); self.canvas.reconcile_vector_edit(); + self.sync_toolbar(); self.refresh_layer_selection(); + self.sync_tree_disclosure(); self.refresh_inspector(); self.updating.set(false); + self.sync_context_inspector(); self.refresh_open_panel(); } fn refresh_layer_selection(&self) { @@ -1064,70 +1155,9 @@ impl EditorShell { // Native class changes can apply styles synchronously. Keep the weak // registry unborrowed while updating this bounded set of realized rows. for (button, selected) in buttons { - button.classes().set("active", selected); - } - } - fn refresh_pages(self: &Rc) { - let host = self.control::("PagesHost"); - let editor = self.editor.borrow(); - self.text("FileTitle", &editor.document.name); - let page = editor.document.page(editor.active_page).map(|page| page.name.as_str()).unwrap_or(""); - self.text("CanvasCaption", &format!("{} / {page}", editor.document.name)); - let rows: Vec<_> = editor - .document - .pages - .iter() - .map(|p| (p.id, p.name.clone())) - .collect(); - if *self.page_rows.borrow() == rows { - for (index, (id, name)) in rows.iter().enumerate() { - let button = host - .children() - .get(index) - .cast:: + + - + + + + + + + + - + - + - - - + + + + + + + + + + + + + + - + + + + + + + + + + + - + + + + + + + + + + +