From 485715f11eb8d082da1fcc341ef78059f7993387 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 23:19:53 +0700 Subject: [PATCH] M1: cursors per tool and modifier, panel widths, outline options, dark pages - Cursors: the engine names a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy on Alt, not-allowed while an instance refuses the edit); the UI shows the system's or draws its own; guides and handles keep their cursors; ui.inspect reports it. - Panels: each edge drags its panel within limits, a double-click resets, widths are per window and kept in the preferences (view.panelWidths); a small window keeps its canvas. - Outline mode: Include hidden layers and Include object bounds (view.outlinesHiddenLayers, view.outlinesObjectBounds), drawn and clickable; light lines on a dark page; check marks in the View menu. - A file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5); text drawn on a dark page is white. - Tests for Enter and Escape on a selected text. --- crates/color/src/lib.rs | 7 + crates/doc/src/geometry.rs | 39 ++- crates/engine/src/catalog.rs | 4 + crates/engine/src/cmd/file.rs | 10 +- crates/engine/src/cmd/page.rs | 4 + crates/engine/src/cmd/view.rs | 51 ++++ crates/engine/src/cursor_tests.rs | 279 ++++++++++++++++++ crates/engine/src/lib.rs | 58 ++++ crates/engine/src/outline_options_tests.rs | 158 ++++++++++ crates/engine/src/theme_pages_tests.rs | 130 ++++++++ crates/engine/src/tools.rs | 151 ++++++++-- .../engine/src/transform_behaviour_tests.rs | 2 +- crates/engine/src/vectoredit/pointer.rs | 18 +- crates/render/src/draw.rs | 28 +- crates/render/src/lib.rs | 26 +- crates/render/src/lod_tests.rs | 12 +- crates/render/src/pattern.rs | 12 +- crates/render/src/retained.rs | 5 + crates/ui-egui/locales/es.toml | 5 + crates/ui-egui/src/canvas.rs | 75 ++--- crates/ui-egui/src/canvas_fixes_tests.rs | 1 + crates/ui-egui/src/cursors.rs | 200 +++++++++++++ crates/ui-egui/src/cursors_tests.rs | 201 +++++++++++++ crates/ui-egui/src/idle_tests.rs | 1 + crates/ui-egui/src/layers.rs | 11 + crates/ui-egui/src/lib.rs | 122 +++++++- crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/panel_width_tests.rs | 230 +++++++++++++++ crates/ui-egui/src/render_worker.rs | 3 + crates/ui-egui/src/text_enter_tests.rs | 79 +++++ crates/ui-egui/src/windows.rs | 10 +- 31 files changed, 1823 insertions(+), 112 deletions(-) create mode 100644 crates/engine/src/cursor_tests.rs create mode 100644 crates/engine/src/outline_options_tests.rs create mode 100644 crates/engine/src/theme_pages_tests.rs create mode 100644 crates/ui-egui/src/cursors.rs create mode 100644 crates/ui-egui/src/cursors_tests.rs create mode 100644 crates/ui-egui/src/panel_width_tests.rs create mode 100644 crates/ui-egui/src/text_enter_tests.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index bcd1b1b..7d0bae9 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -77,6 +77,8 @@ impl Color { pub const DEFAULT_SHAPE: Color = Color::from_rgb8_const(0xD9, 0xD9, 0xD9); /// Default page background (#F5F5F5). pub const DEFAULT_PAGE: Color = Color::from_rgb8_const(0xF5, 0xF5, 0xF5); + /// The background of a page made while the interface shows its dark theme (#1E1E1E). + pub const DARK_PAGE: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r: clamp01(r), g: clamp01(g), b: clamp01(b), a: clamp01(a) } @@ -199,6 +201,11 @@ impl Color { 0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b) } + /// Whether black on this colour reads worse than white (a dark page: new text is white). + pub fn is_dark(self) -> bool { + self.contrast(Color::WHITE) > self.contrast(Color::BLACK) + } + /// WCAG contrast ratio between two opaque colours. pub fn contrast(self, other: Color) -> f32 { let (a, b) = (self.luminance() + 0.05, other.luminance() + 0.05); diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index f41c7e9..06596dd 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -106,7 +106,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool { /// boolean groups are hit where their `result` is. pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { let p = doc.node(page)?; - p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, false).filter(|_| !masked_out(doc, p, *c, pt))) + p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt))) } /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask @@ -129,24 +129,42 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool /// Like [`hit_leaf`], but only within layer `root` (it included). pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { - hit_rec(doc, root, pt, tol, 0, result, false) + hit_rec(doc, root, pt, tol, 0, result, None) } /// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit /// where their outlines are, so a click inside one reaches the layers drawn behind it. -pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { +/// +/// `also` is what outline mode draws besides the shapes: hidden layers are hit like the others +/// when it shows them, and every layer on the edge of its box when it shows object bounds. +pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option { match doc.node(root) { - Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, true)), - _ => hit_rec(doc, root, pt, tol, 0, result, true), + Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))), + _ => hit_rec(doc, root, pt, tol, 0, result, Some(also)), } } -fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, outline: bool) -> Option { +/// What outline mode shows besides the shapes' outlines (see [`hit_outlines`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OutlineHits { + pub hidden: bool, + pub bounds: bool, +} + +/// Whether `pt` is within `tol` of the edge of `n`'s box. +fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> bool { + let lp = doc.world_transform(id).inverse() * pt; + let r = n.local_rect(); + r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp)) +} + +fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option) -> Option { if depth > 512 { return None; } let n = doc.node(id)?; - if !n.visible || n.locked { + let outline = also.is_some(); + if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked { return None; } if let NodeKind::Section { contents_hidden: true } = n.kind { @@ -164,11 +182,12 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result return None; } for c in n.paint_order().rev() { - if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, outline).filter(|_| !masked_out(doc, n, c, pt)) { + if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) { return Some(h); } } - if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) { + if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) || also.is_some_and(|a| a.bounds) && on_box_edge(doc, id, n, pt, tol) + { return Some(id); } None @@ -191,7 +210,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool let at = out.len(); let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true }); if whole { - if hit_rec(doc, id, pt, tol, depth, result, false).is_some() { + if hit_rec(doc, id, pt, tol, depth, result, None).is_some() { out.push((id, depth)); } return; diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 425ed79..7e7ec30 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -91,6 +91,9 @@ pub static MENU: &[MenuItem] = &[ m!("View", SEP, ""), m!("View/Outlines", "Show outlines", "view.outlines"), m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection"), + m!("View/Outlines", SEP, ""), + m!("View/Outlines", "Include hidden layers", "view.outlinesHiddenLayers"), + m!("View/Outlines", "Include object bounds", "view.outlinesObjectBounds"), m!("View", "Pixel preview", "view.pixelPreview"), m!("View", "Mask outlines", "view.maskOutlines"), m!("View", "Frame outlines", "view.frameOutlines"), @@ -98,6 +101,7 @@ pub static MENU: &[MenuItem] = &[ m!("View", "Soft-proof sRGB", "view.proofSrgb"), m!("View", SEP, ""), m!("View/Panels", "Show/Hide UI", "view.toggleUi"), + m!("View/Panels", "Reset panel widths", "view.panelWidths"), m!("View/Panels", "Layers", "view.layers"), m!("View/Panels", "Assets", "view.assets"), m!("View/Panels", "Design", "view.design"), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index fd7dab3..b965492 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,15 @@ pub fn specs() -> Vec { vec![ spec!("file.new", "New design file", "{name?}", false, |s, p| { let name = string(p, "name").unwrap_or_else(|| "Untitled".into()); - s.docs.push(DocState::new(Document::new(name), None)); + let mut doc = Document::new(name); + // A property of the page from here on: a later change of theme leaves it alone. + let background = s.new_page_background(); + for page in doc.pages.clone() { + if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { + *b = background; + } + } + s.docs.push(DocState::new(doc, None)); s.active = s.docs.len() - 1; Ok(json!({"index": s.active})) }), diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index e33367e..b2d4698 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -26,9 +26,13 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { + let background = s.new_page_background(); let doc = s.doc_mut()?; let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1)); let id = doc.add_page(&name); + if let NodeKind::Page { background: b, .. } = &mut doc.get_mut(id)?.kind { + *b = background; + } if doc.is_divider(id) { return Ok(json!({"id": id, "divider": true})); } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 8afb327..09a3a88 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -19,6 +19,32 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul Ok(json!({"on": *slot})) } +/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command +/// that isn't one. +pub fn checked(s: &Session, id: &str) -> Option { + let v = &s.ds().ok()?.view; + Some(match id { + "view.pixelGrid" => v.pixel_grid, + "view.layoutGrids" => v.layout_grids, + "view.rulers" => v.rulers, + "view.slices" => v.show_slices, + "view.comments" => v.comments, + "view.multiplayerCursors" => v.multiplayer_cursors, + "view.outlines" => v.outlines, + "view.outlinesSelection" => v.outlines_selection, + "view.outlinesHiddenLayers" => v.outline_hidden, + "view.outlinesObjectBounds" => v.outline_bounds, + "view.maskOutlines" => v.mask_outlines, + "view.pixelPreview" => v.pixel_preview, + "view.frameOutlines" => v.frame_outlines, + "view.proofSrgb" => v.proof_srgb, + "view.snapGeometry" => v.snap_geometry, + "view.snapObjects" => v.snap_objects, + "view.snapPixel" => v.snap_pixel, + _ => return None, + }) +} + /// Pixel preview: toggles, or turns on at a resolution of 1x or 2x. fn pixel_preview(s: &mut Session, p: &Value) -> Result { let scale = match p.get("scale") { @@ -214,6 +240,20 @@ pub fn specs() -> Vec { spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { flag(s, p, |v| &mut v.outlines_selection) }), + spec!( + "view.outlinesHiddenLayers", + "Include hidden layers in outlines", + "{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there", + false, + |s, p| { flag(s, p, |v| &mut v.outline_hidden) } + ), + spec!( + "view.outlinesObjectBounds", + "Include object bounds in outlines", + "{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer", + false, + |s, p| { flag(s, p, |v| &mut v.outline_bounds) } + ), spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), @@ -235,6 +275,17 @@ pub fn specs() -> Vec { spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")), spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")), spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")), + spec!( + "view.panelWidths", + "Reset panel widths", + "{left?, right? (the panels' widths in pixels, kept within their limits)} without either, both go back to their default width (a double-click on a panel's edge does it for that panel)", + false, + |s, p| { + let width = |k| num(p, k).filter(|w| w.is_finite()).map(|w| w.clamp(0.0, 10_000.0)); + s.request_ui("panel.widths", json!({"left": width("left"), "right": width("right")})); + ok() + } + ), // Several windows: the desktop app opens and focuses them (no-ops headless and in the browser). spec!( "window.new", diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs new file mode 100644 index 0000000..39a18ce --- /dev/null +++ b/crates/engine/src/cursor_tests.rs @@ -0,0 +1,279 @@ +//! The cursor each tool asks for (`tool.overlay.cursor`), by tool, modifier and what the pointer +//! is over: tables of tool × modifiers × hover target → cursor, the cursors of gestures under way +//! (panning, duplicating, rotating, an edit an instance refuses) and of vector edit mode. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{Cursor, PenCursor, PointerEvent, PointerKind, ToolId}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[derive(Clone, Copy, Default)] +struct Mods { + shift: bool, + alt: bool, + cmd: bool, +} + +const NONE: Mods = Mods { shift: false, alt: false, cmd: false }; +const ALT: Mods = Mods { shift: false, alt: true, cmd: false }; +const CMD: Mods = Mods { shift: false, alt: false, cmd: true }; + +fn ev(kind: PointerKind, (x, y): (f64, f64), m: Mods) -> PointerEvent { + PointerEvent { shift: m.shift, alt: m.alt, cmd: m.cmd, ..PointerEvent::at(kind, x, y, 1.0) } +} + +fn hover(s: &mut Session, at: (f64, f64), m: Mods) -> Cursor { + s.pointer(&ev(PointerKind::Hover, at, m)).unwrap(); + s.tool.overlay.cursor +} + +/// A resize cursor's direction, 0–180° (it has two heads). +fn resize(c: Cursor) -> f64 { + match c { + Cursor::Resize(a) => a.rem_euclid(180.0), + other => panic!("not a resize cursor: {other:?}"), + } +} + +fn rotate(c: Cursor) -> f64 { + match c { + Cursor::Rotate(a) => a.rem_euclid(360.0), + other => panic!("not a rotate cursor: {other:?}"), + } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected. +fn scene() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))); + let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}}))); + ex(&mut s, "select.set", json!({"ids": [r]})); + (s, r, t) +} + +const EMPTY: (f64, f64) = (700.0, 600.0); +const ON_SQUARE: (f64, f64) = (150.0, 150.0); +const ON_TEXT: (f64, f64) = (420.0, 120.0); + +#[test] +fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() { + use Cursor::*; + // (tool, modifiers, where, cursor, why) + type Row = (ToolId, Mods, (f64, f64), Cursor, &'static str); + let rows: Vec = vec![ + (ToolId::Move, NONE, EMPTY, Default, "the arrow over empty canvas"), + (ToolId::Move, NONE, ON_SQUARE, Default, "the arrow over a layer"), + (ToolId::Move, NONE, ON_TEXT, Default, "the arrow over a text that isn't being edited"), + (ToolId::Move, ALT, ON_SQUARE, Copy, "Alt over a selected layer: a drag makes a copy"), + (ToolId::Move, ALT, ON_TEXT, Default, "Alt over another layer measures to it"), + (ToolId::Move, ALT, EMPTY, Default, "Alt over empty canvas"), + (ToolId::Scale, NONE, ON_SQUARE, Default, "the Scale tool away from the handles"), + (ToolId::Scale, ALT, ON_SQUARE, Default, "the Scale tool doesn't duplicate"), + (ToolId::Hand, NONE, EMPTY, Grab, "the open hand"), + (ToolId::Hand, NONE, ON_SQUARE, Grab, "the open hand over a layer too"), + (ToolId::Frame, NONE, EMPTY, Crosshair, "frame"), + (ToolId::Section, NONE, EMPTY, Crosshair, "section"), + (ToolId::Slice, NONE, EMPTY, Crosshair, "slice"), + (ToolId::Rectangle, NONE, ON_SQUARE, Crosshair, "shapes are drawn over layers too"), + (ToolId::Line, NONE, EMPTY, Crosshair, "line"), + (ToolId::Arrow, NONE, EMPTY, Crosshair, "arrow"), + (ToolId::Ellipse, NONE, EMPTY, Crosshair, "ellipse"), + (ToolId::Polygon, NONE, EMPTY, Crosshair, "polygon"), + (ToolId::Star, NONE, EMPTY, Crosshair, "star"), + (ToolId::Pencil, NONE, EMPTY, Crosshair, "pencil"), + (ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"), + (ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"), + (ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"), + (ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"), + (ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"), + (ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"), + ]; + for (tool, m, at, want, why) in rows { + let (mut s, ..) = scene(); + s.set_tool(tool); + assert_eq!(s.tool.overlay.cursor, tool.cursor(), "{tool:?}: its cursor shows as soon as it is chosen"); + assert_eq!(hover(&mut s, at, m), want, "{tool:?} at {at:?}: {why}"); + } + // Every tool has a row above. + for t in ToolId::ALL { + assert!(matches!(t.cursor(), Default | Crosshair | Grab | Pen(_) | Comment | Eyedropper), "{t:?}"); + } +} + +#[test] +fn handles_show_arrows_that_follow_the_handle_and_the_boxs_rotation() { + let (mut s, r, _) = scene(); + // (where, the arrow's direction in degrees: 0 = left-right, 45 = top left to bottom right) + let handles = [((200.0, 150.0), 0.0), ((150.0, 100.0), 90.0), ((200.0, 200.0), 45.0), ((100.0, 200.0), 135.0), ((100.0, 100.0), 45.0)]; + for (at, want) in handles { + let got = resize(hover(&mut s, at, NONE)); + assert!(close(got, want), "handle at {at:?}: {got}° (wanted {want}°)"); + } + // Just outside each corner: the rotate cursor, turned to that corner (from the centre). + for (at, want) in [((208.0, 208.0), 45.0), ((92.0, 208.0), 135.0), ((92.0, 92.0), 225.0), ((208.0, 92.0), 315.0)] { + let got = rotate(hover(&mut s, at, NONE)); + assert!(close(got, want), "outside the corner at {at:?}: {got}° (wanted {want}°)"); + } + // The Scale tool's handles too. + s.set_tool(ToolId::Scale); + assert!(close(resize(hover(&mut s, (200.0, 150.0), NONE)), 0.0)); + s.set_tool(ToolId::Move); + // A box turned by 30° turns its cursors by 30°: the handle that was at the right edge. + ex(&mut s, "node.set", json!({"id": r, "rotation": 30})); + let corners = crate::tools::selection_box(s.doc().unwrap(), &[r]).unwrap(); + let mid = |a: usize, b: usize| ((corners[a].x + corners[b].x) / 2.0, (corners[a].y + corners[b].y) / 2.0); + let edge = resize(hover(&mut s, mid(1, 2), NONE)); + assert!(close(edge, 30.0) || close(edge, 150.0), "the right edge's arrow turned with the box: {edge}°"); + let centre = mid(0, 2); + let c = corners[2]; + let out = (c.x + (c.x - centre.0) * 0.15, c.y + (c.y - centre.1) * 0.15); + let want = (c.y - centre.1).atan2(c.x - centre.0).to_degrees().rem_euclid(360.0); + let got = rotate(hover(&mut s, out, NONE)); + assert!(close(got, want) && !close(got, 45.0), "the rotate cursor points away from the turned box's centre: {got}° (wanted {want}°)"); + // A locked layer has no handles: the arrow. + ex(&mut s, "node.set", json!({"id": r, "rotation": 0, "locked": true})); + assert_eq!(hover(&mut s, (200.0, 150.0), NONE), Cursor::Default); +} + +#[test] +fn gestures_show_what_they_are_doing() { + // Panning: the hand closes. + let (mut s, r, _) = scene(); + s.set_tool(ToolId::Hand); + s.pointer(&ev(PointerKind::Down, EMPTY, NONE)).unwrap(); + assert_eq!(s.tool.overlay.cursor, Cursor::Grabbing); + s.pointer(&ev(PointerKind::Up, EMPTY, NONE)).unwrap(); + assert_eq!(hover(&mut s, EMPTY, NONE), Cursor::Grab, "and opens again"); + // Alt-drag: the copy cursor from the press to the release; a plain drag: the arrow. + for (m, want) in [(ALT, Cursor::Copy), (NONE, Cursor::Default)] { + let (mut s, ..) = scene(); + s.pointer(&ev(PointerKind::Down, ON_SQUARE, m)).unwrap(); + assert_eq!(s.tool.overlay.cursor, want, "on the press"); + s.pointer(&ev(PointerKind::Move, (180.0, 170.0), m)).unwrap(); + s.pointer(&ev(PointerKind::Move, (260.0, 190.0), m)).unwrap(); + assert_eq!(s.tool.overlay.cursor, want, "while dragging"); + s.pointer(&ev(PointerKind::Up, (260.0, 190.0), m)).unwrap(); + } + // Rotating: the cursor turns with the pointer about the centre. + s.set_tool(ToolId::Move); + ex(&mut s, "select.set", json!({"ids": [r]})); + s.pointer(&ev(PointerKind::Down, (208.0, 208.0), NONE)).unwrap(); + for (at, want) in [((150.0, 260.0), 90.0), ((40.0, 150.0), 180.0), ((150.0, 40.0), 270.0)] { + s.pointer(&ev(PointerKind::Move, at, NONE)).unwrap(); + let got = rotate(s.tool.overlay.cursor); + assert!(close(got, want), "rotating with the pointer at {at:?}: {got}° (wanted {want}°)"); + } + s.pointer(&ev(PointerKind::Up, (150.0, 40.0), NONE)).unwrap(); + // While a text is edited, the I-beam over it (with the Move tool) and the arrow elsewhere. + let (mut s, _, t) = scene(); + ex(&mut s, "text.edit.begin", json!({"id": t})); + assert_eq!(hover(&mut s, ON_TEXT, NONE), Cursor::Text); + assert_eq!(hover(&mut s, EMPTY, NONE), Cursor::Default); + ex(&mut s, "text.edit.end", json!({})); + assert_eq!(hover(&mut s, ON_TEXT, NONE), Cursor::Default, "editing over: the arrow"); +} + +#[test] +fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}))); + ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "parent": f})); + let main = id(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let inst = id(&ex(&mut s, "instance.create", json!({"component": main, "x": 400, "y": 0}))); + // The rectangle inside the instance: (450,50)–(550,150). + let inner = s.doc().unwrap().node(inst).unwrap().children[0]; + let before = s.doc().unwrap().world_bounds(inner).unwrap(); + assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0)); + ex(&mut s, "select.set", json!({"ids": [inner]})); + // Resizing it by its right edge: only the main component can. + assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow"); + s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap(); + assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed); + s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone"); + // Moving it: refused too. + s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap(); + assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed); + s.pointer(&ev(PointerKind::Up, (520.0, 120.0), NONE)).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone"); +} + +#[test] +fn the_pen_shows_what_a_click_would_do() { + // The Pen tool drawing a new path: three points, then over each of them. + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + for at in [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0)] { + s.pointer(&ev(PointerKind::Down, at, NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, at, NONE)).unwrap(); + } + let rows = [ + ((300.0, 300.0), PenCursor::New, "empty canvas: a new point"), + ((101.0, 101.0), PenCursor::Close, "its first point: closes the path"), + ((200.0, 101.0), PenCursor::Close, "another of its points: connects to it and ends the path"), + ((200.0, 200.0), PenCursor::New, "its last point: nothing to connect"), + ]; + for (at, want, why) in rows { + assert_eq!(hover(&mut s, at, NONE), Cursor::Pen(want), "{why}"); + } + s.escape().unwrap(); + // With an open path selected, its end points go on with it. + let path = id(&ex(&mut s, "vector.create", json!({"points": [[400, 100], [500, 100], [500, 200]], "closed": false}))); + ex(&mut s, "select.set", json!({"ids": [path]})); + s.set_tool(ToolId::Pen); + assert_eq!(hover(&mut s, (500.0, 200.0), NONE), Cursor::Pen(PenCursor::Close), "an end point of the selected open path"); + assert_eq!(hover(&mut s, (500.0, 100.0), NONE), Cursor::Pen(PenCursor::New), "its middle point isn't an end"); +} + +#[test] +fn vector_edit_mode_shows_each_tool_and_modifier() { + let mut s = Session::new(); + let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false}))); + ex(&mut s, "vector.edit.begin", json!({"id": path})); + let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0)); + use Cursor::*; + // (vector tool, modifiers, where, cursor, why) + type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str); + let rows: Vec = vec![ + ("move", NONE, point, Default, "the arrow over a point"), + ("move", NONE, segment, Default, "the arrow over a segment"), + ("move", NONE, empty, Default, "the arrow elsewhere"), + ("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"), + ("move", ALT, point, Default, "Alt over a point adds nothing"), + ("move", CMD, segment, Pen(PenCursor::Bend), "Cmd/Ctrl bends the segment"), + ("move", CMD, point, Pen(PenCursor::Bend), "Cmd/Ctrl bends the point's handles"), + ("move", CMD, empty, Crosshair, "Cmd/Ctrl over nothing"), + ("pen", NONE, empty, Pen(PenCursor::New), "the pen: a new point"), + ("pen", NONE, point, Pen(PenCursor::Close), "the pen over a point: connects to it or goes on from it"), + ("pen", NONE, segment, Pen(PenCursor::Add), "the pen over a segment: adds a point"), + ("bend", NONE, segment, Pen(PenCursor::Bend), "the Bend tool"), + ("bend", NONE, empty, Pen(PenCursor::Bend), "the Bend tool anywhere"), + ("paint", NONE, empty, Crosshair, "paint bucket"), + ("lasso", NONE, empty, Crosshair, "lasso"), + ("cut", NONE, segment, Crosshair, "cut"), + ("eraser", NONE, empty, Crosshair, "eraser (its brush is drawn around the pointer)"), + ("width", NONE, segment, Crosshair, "variable width"), + ]; + for (tool, m, at, want, why) in rows { + ex(&mut s, "vector.edit.tool", json!({"tool": tool})); + assert_eq!(hover(&mut s, at, m), want, "{tool} at {at:?}: {why}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 413cba1..662d4b2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -137,6 +137,10 @@ pub struct ViewState { pub outlines: bool, /// Outline mode for the selected layers only (the rest draws normally). pub outlines_selection: bool, + /// Outline mode also outlines hidden layers (View ▸ Outlines ▸ Include hidden layers). + pub outline_hidden: bool, + /// Outline mode also draws every layer's box (View ▸ Outlines ▸ Include object bounds). + pub outline_bounds: bool, /// Outline the masks (they don't draw themselves). pub mask_outlines: bool, pub pixel_preview: bool, @@ -168,6 +172,8 @@ impl Default for ViewState { rulers: false, outlines: false, outlines_selection: false, + outline_hidden: false, + outline_bounds: false, mask_outlines: false, pixel_preview: false, pixel_scale: 1, @@ -543,6 +549,9 @@ pub struct Prefs { pub number_keys_opacity: bool, pub show_dimensions: bool, pub theme: String, + /// The widths of the main window's left and right panels (the interface keeps them within its + /// limits; other windows have their own while they are open). + pub panel_widths: (f32, f32), /// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser /// language). Languages without a catalog show English. pub language: String, @@ -584,6 +593,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, theme: "light".into(), + panel_widths: (240.0, 240.0), language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, @@ -629,6 +639,10 @@ pub struct Session { pub prefs: Prefs, pub tool: tools::ToolState, pub ui_requests: Vec, + /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages + /// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no + /// theme, so what scripts make is the same everywhere. + pub ui_dark: Option, /// Command log (id + params) for actions/automation. pub journal: Vec<(String, Value)>, /// Recently used colours. @@ -671,6 +685,7 @@ impl Session { prefs: Prefs::default(), tool: tools::ToolState::default(), ui_requests: Vec::new(), + ui_dark: None, journal: Vec::new(), recent_colors: Vec::new(), last_command: None, @@ -682,6 +697,43 @@ impl Session { } } + /// The background new pages get: the dark page colour while the interface is dark. + pub fn new_page_background(&self) -> figcraft_color::Color { + if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE } + } + + /// Tell the engine the interface's theme (the UI does, every frame). The first time, a new + /// file nothing was done to yet (the one the app opened with, made before the theme was + /// known) takes the theme's page colour; later changes of theme leave existing pages alone. + pub fn set_ui_dark(&mut self, dark: bool) { + if self.ui_dark.replace(dark).is_some() || !dark { + return; + } + for ds in &mut self.docs { + let untouched = ds.path.is_none() && ds.history.undo.is_empty() && ds.history.redo.is_empty() && !ds.is_dirty() && ds.versions.is_empty(); + let pages = ds.doc.pages.clone(); + let blank = pages.iter().all(|p| { + ds.doc.node(*p).is_some_and(|n| { + n.children.is_empty() + && matches!(n.kind, figcraft_doc::NodeKind::Page { background, .. } if background == figcraft_color::Color::DEFAULT_PAGE) + }) + }); + if !(untouched && blank) { + continue; + } + let mut doc = (*ds.doc).clone(); + for p in pages { + if let Some(figcraft_doc::NodeKind::Page { background, .. }) = doc.node_mut(p).map(|n| &mut n.kind) { + *background = figcraft_color::Color::DARK_PAGE; + } + } + // Still a file without changes. + ds.doc = Arc::new(doc); + ds.saved_doc = ds.doc.clone(); + ds.revision += 1; + } + } + pub fn with_document(doc: Document) -> Self { let mut s = Self::empty(); s.docs.push(DocState::new(doc, None)); @@ -1301,6 +1353,8 @@ mod components_behaviour_tests; #[cfg(test)] mod components_scale_tests; #[cfg(test)] +mod cursor_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1369,6 +1423,8 @@ mod menu_gaps_tests; #[cfg(test)] mod nested_override_tests; #[cfg(test)] +mod outline_options_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; @@ -1457,6 +1513,8 @@ mod text_style_picker_tests; #[cfg(test)] mod text_tail_tests; #[cfg(test)] +mod theme_pages_tests; +#[cfg(test)] mod tiff_tests; #[cfg(test)] mod transform_behaviour_tests; diff --git a/crates/engine/src/outline_options_tests.rs b/crates/engine/src/outline_options_tests.rs new file mode 100644 index 0000000..09a46a7 --- /dev/null +++ b/crates/engine/src/outline_options_tests.rs @@ -0,0 +1,158 @@ +//! Outline mode's two options, View ▸ Outlines ▸ Include hidden layers and Include object bounds: +//! what the renderer draws with each on and off, that a click picks what is drawn (and nothing +//! that isn't), and the commands themselves (toggles of the view, checked in the menu). + +use figcraft_doc::NodeId; +use figcraft_render::{Options, Outline, View, render_view, to_rgba8}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn click(s: &mut Session, x: f64, y: f64) { + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); +} + +/// A shown rectangle (0,0 100×100), a hidden one (200,0 100×100), an ellipse (0,200 100×100) +/// and a group of two small rectangles whose box is (200,200)–(300,300). +fn scene(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let shown = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let hidden = id(&ex(s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 100, "height": 100}))); + ex(s, "node.set", json!({"id": hidden, "visible": false})); + let ellipse = id(&ex(s, "shape.ellipse", json!({"x": 0, "y": 200, "width": 100, "height": 100}))); + let a = id(&ex(s, "shape.rectangle", json!({"x": 200, "y": 200, "width": 20, "height": 20}))); + let b = id(&ex(s, "shape.rectangle", json!({"x": 280, "y": 280, "width": 20, "height": 20}))); + let group = id(&ex(s, "object.group", json!({"ids": [a, b]}))); + ex(s, "select.none", json!({})); + (shown, hidden, ellipse, group) +} + +/// Whether the canvas, drawn as the view's flags say, has an outline pixel (unlike the page) at +/// page point (x, y). +fn drawn(s: &Session, x: f64, y: f64) -> bool { + let ds = s.ds().unwrap(); + let v = &ds.view; + let opts = Options { + outline_mode: v.outlines, + outline: Outline { hidden: v.outline_hidden, bounds: v.outline_bounds, light: false }, + ..Default::default() + }; + let view = View { origin: (-20.0, -20.0), scale: 1.0, width: 360, height: 360 }; + let pix = render_view(&ds.doc, ds.page, &view, &opts).unwrap(); + let rgba = to_rgba8(&pix); + let at = |px: i64, py: i64| { + let i = ((py + 20) * 360 + (px + 20)) as usize * 4; + [rgba[i], rgba[i + 1], rgba[i + 2]] + }; + let page = at(-10, -10); + // A 1 px line can fall on either side of the coordinate. + (-1..=1).any(|d| at(x as i64 + d, y as i64) != page || at(x as i64, y as i64 + d) != page) +} + +#[test] +fn the_options_are_view_toggles_checked_in_the_menu() { + let mut s = Session::new(); + for (cmd, read) in [ + ("view.outlinesHiddenLayers", (|s: &Session| s.ds().unwrap().view.outline_hidden) as fn(&Session) -> bool), + ("view.outlinesObjectBounds", |s: &Session| s.ds().unwrap().view.outline_bounds), + ] { + assert!(!read(&s), "{cmd} is off in a new file"); + assert_eq!(crate::cmd::view::checked(&s, cmd), Some(false)); + assert_eq!(ex(&mut s, cmd, json!({}))["on"], true, "{cmd} toggles"); + assert!(read(&s)); + assert_eq!(crate::cmd::view::checked(&s, cmd), Some(true), "{cmd} shows its check mark"); + assert_eq!(ex(&mut s, cmd, json!({"on": true}))["on"], true, "{cmd} {{on}} sets"); + assert_eq!(ex(&mut s, cmd, json!({"on": false}))["on"], false); + // In the View ▸ Outlines menu, with a Spanish-translatable label of its own. + let item = crate::catalog::MENU.iter().find(|m| m.id == cmd).unwrap_or_else(|| panic!("{cmd} has no menu item")); + assert_eq!(item.menu, "View/Outlines"); + // A view setting: nothing to undo, the file isn't changed. + assert!(s.ds().unwrap().history.undo.is_empty()); + assert!(!s.ds().unwrap().is_dirty()); + } + assert_eq!(crate::cmd::view::checked(&s, "file.save"), None, "not a toggle"); +} + +#[test] +fn outline_mode_draws_hidden_layers_and_object_bounds_when_asked() { + let mut s = Session::new(); + scene(&mut s); + // (outlines, hidden layers, object bounds) → drawn at: the shown rectangle's edge, the hidden + // rectangle's edge, the ellipse's box corner (off its outline), the group's box edge. + let rows = [ + ((true, false, false), [true, false, false, false], "outlines alone: shapes that show"), + ((true, true, false), [true, true, false, false], "hidden layers are outlined too"), + ((true, false, true), [true, false, true, true], "object bounds: the boxes of the ellipse and the group"), + ((true, true, true), [true, true, true, true], "both"), + ((false, true, true), [true, false, false, false], "the options do nothing outside outline mode (the rectangle is drawn filled)"), + ]; + for ((outlines, hidden, bounds), want, why) in rows { + ex(&mut s, "view.outlines", json!({"on": outlines})); + ex(&mut s, "view.outlinesHiddenLayers", json!({"on": hidden})); + ex(&mut s, "view.outlinesObjectBounds", json!({"on": bounds})); + let got = [drawn(&s, 50.0, 0.0), drawn(&s, 250.0, 0.0), drawn(&s, 1.0, 201.0), drawn(&s, 250.0, 200.0)]; + assert_eq!(got, want, "{why}"); + } +} + +#[test] +fn a_click_picks_what_outline_mode_draws() { + let mut s = Session::new(); + let (shown, hidden, ellipse, group) = scene(&mut s); + // (hidden layers, object bounds, click, selected, why) + type Row = (bool, bool, (f64, f64), Vec, &'static str); + let rows: Vec = vec![ + (false, false, (50.0, 0.5), vec![shown], "a shape is clicked on its outline"), + (false, false, (250.0, 0.5), vec![], "a hidden layer isn't drawn: nothing to click"), + (true, false, (250.0, 0.5), vec![hidden], "a hidden layer that is outlined is clicked on its outline"), + (true, false, (250.0, 50.0), vec![], "inside it there is nothing, as for any shape in outline mode"), + (false, false, (0.5, 200.5), vec![], "the corner of an ellipse's box is off its outline"), + (false, true, (0.5, 200.5), vec![ellipse], "with object bounds the box is drawn and clicked"), + (false, false, (250.0, 200.5), vec![], "a group's box isn't drawn without object bounds"), + (false, true, (250.0, 200.5), vec![group], "with object bounds a group is clicked on its box"), + (false, true, (250.0, 250.0), vec![], "inside the box there is nothing"), + ]; + ex(&mut s, "view.outlines", json!({"on": true})); + for (hide, bounds, p, after, why) in rows { + ex(&mut s, "view.outlinesHiddenLayers", json!({"on": hide})); + ex(&mut s, "view.outlinesObjectBounds", json!({"on": bounds})); + ex(&mut s, "select.none", json!({})); + assert_eq!(drawn(&s, p.0, p.1), !after.is_empty(), "what is clicked is what is drawn: {why}"); + click(&mut s, p.0, p.1); + assert_eq!(s.selection(), after, "{why}"); + } + // Outside outline mode the options change nothing: hidden layers aren't clicked. + ex(&mut s, "view.outlines", json!({"on": false})); + ex(&mut s, "view.outlinesHiddenLayers", json!({"on": true})); + ex(&mut s, "select.none", json!({})); + click(&mut s, 250.0, 50.0); + assert!(s.selection().is_empty()); +} + +#[test] +fn outlines_are_light_on_a_dark_page() { + let mut s = Session::new(); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})); + ex(&mut s, "view.outlines", json!({"on": true})); + for (page, value, light) in [("#F5F5F5", 0xF5_i32, false), ("#1E1E1E", 0x1E, true)] { + ex(&mut s, "page.setBackground", json!({"color": page})); + let ds = s.ds().unwrap(); + let view = View { origin: (-4.0, 46.0), scale: 4.0, width: 32, height: 32 }; + let pix = render_view(&ds.doc, ds.page, &view, &Options { outline_mode: true, ..Default::default() }).unwrap(); + let rgba = to_rgba8(&pix); + // The line (a pixel wide, antialiased) is lighter than a dark page and darker than a light one. + let brightest = i32::from(rgba.chunks(4).map(|p| p[0]).max().unwrap()); + let darkest = i32::from(rgba.chunks(4).map(|p| p[0]).min().unwrap()); + assert_eq!((brightest > value + 0x30, darkest < value - 0x30), (light, !light), "outline on a {page} page"); + } +} diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs new file mode 100644 index 0000000..d652b57 --- /dev/null +++ b/crates/engine/src/theme_pages_tests.rs @@ -0,0 +1,130 @@ +//! The page colour of new files and pages follows the interface's theme when they are made (dark: +//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface +//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is +//! white on a dark page and black on a light one. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn background(s: &Session, page: NodeId) -> Color { + match s.doc().unwrap().node(page).unwrap().kind { + NodeKind::Page { background, .. } => background, + _ => panic!("{page} is not a page"), + } +} + +fn backgrounds(s: &Session) -> Vec { + s.doc().unwrap().pages.iter().map(|p| background(s, *p).to_css_hex().to_uppercase()).collect() +} + +/// Click with the Text tool at (x, y) on the current page; the new text layer. +fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId { + ex(s, "tool.text", json!({})); + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); + let id = s.selection()[0]; + ex(s, "text.edit.insert", json!({"text": "Hi"})); + ex(s, "text.edit.end", json!({})); + id +} + +#[test] +fn new_files_and_pages_take_the_theme_they_are_made_in() { + // (theme the interface reported, colour of a new file's page and of an added page) + for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] { + let mut s = Session::empty(); + if let Some(d) = dark { + s.set_ui_dark(d); + } + ex(&mut s, "file.new", json!({})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), [want, want], "theme {dark:?}"); + // One undo step for the page, with its colour. + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(backgrounds(&s), [want, want], "theme {dark:?} after undo and redo"); + } +} + +#[test] +fn a_change_of_theme_leaves_existing_pages_alone() { + let mut s = Session::empty(); + s.set_ui_dark(true); + ex(&mut s, "file.new", json!({})); + s.set_ui_dark(false); + ex(&mut s, "page.add", json!({})); + s.set_ui_dark(true); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with"); + // A copy of a page is that page's colour, whatever the theme. + let first = s.doc().unwrap().pages[0]; + s.set_ui_dark(false); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({"id": first}))["id"]).unwrap(); + assert_eq!(background(&s, copy), Color::DARK_PAGE); +} + +#[test] +fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() { + // Made before the interface knew its theme, nothing done to it yet. + let mut s = Session::new(); + s.set_ui_dark(true); + assert_eq!(backgrounds(&s), ["#1E1E1E"]); + assert!(!s.ds().unwrap().is_dirty(), "still a file without changes"); + assert!(s.ds().unwrap().history.undo.is_empty()); + // A file with something in it, or one whose page colour was chosen, is left as it is. + let mut s = Session::new(); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + s.set_ui_dark(true); + assert_eq!(backgrounds(&s), ["#F5F5F5"]); + let mut s = Session::new(); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + s.set_ui_dark(true); + assert_eq!(backgrounds(&s), ["#336699"]); + // Later changes of theme never repaint a page. + let mut s = Session::new(); + s.set_ui_dark(false); + s.set_ui_dark(true); + assert_eq!(backgrounds(&s), ["#F5F5F5"]); +} + +#[test] +fn opened_files_keep_their_page_colour() { + let dir = std::env::temp_dir().join(format!("figcraft-theme-pages-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("light.figcraft"); + let mut s = Session::new(); + ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()})); + let mut s = Session::empty(); + s.set_ui_dark(true); + ex(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + assert_eq!(backgrounds(&s), ["#F5F5F5"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { + // (page colour, the new text's fill): by the page, not the theme. + for (page, want) in [("#1E1E1E", Color::WHITE), ("#F5F5F5", Color::BLACK), ("#0B1F3A", Color::WHITE), ("#FFE08A", Color::BLACK)] { + let mut s = Session::new(); + ex(&mut s, "page.setBackground", json!({"color": page})); + let t = draw_text(&mut s, 40.0, 40.0); + assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}"); + } + // In a frame on a dark page too; text made by a command keeps the model's black (scripts). + let mut s = Session::empty(); + s.set_ui_dark(true); + ex(&mut s, "file.new", json!({})); + ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + let t = draw_text(&mut s, 50.0, 50.0); + assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]); + let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); + assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::BLACK)]); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index c2da892..a4b995a 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -200,14 +200,52 @@ pub enum Cursor { Move, /// Resize along an angle (degrees, 0 = horizontal). Resize(f64), - Rotate, + /// Rotate, turned to the corner it is near: the direction from the box's centre to the corner + /// (while rotating: to the pointer), in degrees, 0 = right and 90 = down. + Rotate(f64), Grab, Grabbing, Text, - Pen, + Pen(PenCursor), + /// A drag here makes a copy (Alt). Copy, /// Over a clickable part of a widget. Pointer, + Eyedropper, + Comment, + /// What the gesture is doing will be undone when it ends (an edit only a main component can + /// make). + NotAllowed, +} + +/// What a click with the pen would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum PenCursor { + /// A new point. + #[default] + New, + /// On a point: closes the path, connects to the point or goes on from it. + Close, + /// On a segment: adds a point there. + Add, + /// Bends the segment or the point's handles (the Bend tool; Cmd/Ctrl in vector edit mode). + Bend, +} + +impl ToolId { + /// The tool's cursor over empty canvas. + pub fn cursor(self) -> Cursor { + match self { + ToolId::Hand => Cursor::Grab, + ToolId::Pen => Cursor::Pen(PenCursor::New), + ToolId::Comment => Cursor::Comment, + ToolId::Eyedropper => Cursor::Eyedropper, + ToolId::Move | ToolId::Scale => Cursor::Default, + // The other tools make something where they are pressed: shapes, frames, slices, pencil + // strokes, a new text (over a text the Text tool shows the I-beam instead). + _ => Cursor::Crosshair, + } + } } /// Selection handle kinds: corners (0 tl, 1 tr, 2 br, 3 bl), edges (0 t, 1 r, 2 b, 3 l) and the @@ -646,7 +684,10 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); Cursor::Resize(angle(a, b) + 90.0) } - Handle::Rotate(_) => Cursor::Rotate, + Handle::Rotate(i) => { + let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); + Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) + } } } @@ -820,7 +861,9 @@ impl Session { } self.tool.tool = t; self.tool.gesture = Gesture::None; - self.tool.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, ..Overlay::default() }; + // The tool's cursor shows before the pointer moves. + self.tool.overlay = + Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; self.tool.shape_builder = None; if t == ToolId::ShapeBuilder { // Regions show straight away, before the pointer moves. @@ -914,13 +957,18 @@ impl Session { let tool = self.tool.tool; let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - let mut cursor = match tool { - ToolId::Hand => Cursor::Grab, - ToolId::Text => Cursor::Text, - ToolId::Pen | ToolId::Pencil => Cursor::Pen, - t if t.draws_shape() => Cursor::Crosshair, - _ => Cursor::Default, - }; + let mut cursor = tool.cursor(); + // The text being edited, and with the Text tool any text, takes the I-beam: a click there + // puts the caret. + let on_edited = ds + .text_edit + .as_ref() + .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); + let on_text = + |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + if tool == ToolId::Text && (on_edited || on_text(self)) { + cursor = Cursor::Text; + } let mut hover = None; let mut guides = Vec::new(); let mut measure_box = None; @@ -928,7 +976,9 @@ impl Session { self.tool.overlay.arc_hover = arc_hover; let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; self.tool.overlay.shape_hover = shape_hover; - if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { + if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { + cursor = Cursor::Text; + } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { cursor = resize_cursor(&doc, &sel, h); } else { @@ -937,6 +987,10 @@ impl Session { cursor = Cursor::Pointer; } hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // Alt over a selected layer: a drag from here duplicates it. + if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + cursor = Cursor::Copy; + } if ev.alt || self.tool.redlines { use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the @@ -963,6 +1017,7 @@ impl Session { // Where a click would land (snapped), with its guides. let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; guides = g; + self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); } @@ -976,6 +1031,34 @@ impl Session { Ok(()) } + /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first + /// point, connect to another of its points, go on from a selected open path's end, or add a + /// new point (the same tests as [`Self::pen_down`]). + fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { + let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; + let on_point = match &self.tool.pen { + None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), + Some(pen) => { + let n = pen.points.len(); + (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) + || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) + } + }; + if on_point { PenCursor::Close } else { PenCursor::New } + } + + /// Whether what the gesture did to `ids` so far is an edit only a main component can make + /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. + fn refused(&self, ids: &[NodeId], moved: bool) -> bool { + let Ok(ds) = self.ds() else { return false }; + ds.interaction.as_ref().is_some_and(|it| { + ids.iter().any(|m| { + (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) + || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() + }) + }) + } + fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -1213,6 +1296,13 @@ impl Session { }); self.tool.overlay.drop_cell = drop_cell; self.tool.overlay.drop_target = drop_target; + self.tool.overlay.cursor = if self.refused(&placed, true) { + Cursor::NotAllowed + } else if dup { + Cursor::Copy + } else { + Cursor::Default + }; self.tool.gesture = g.clone(); if let Gesture::Move { moved, .. } = &mut self.tool.gesture { *moved = true; @@ -1222,6 +1312,9 @@ impl Session { Gesture::Resize { start, r, scale_tool, .. } => { let res = self.resize_drag(ev, *start, r, *scale_tool); self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + if self.refused(&self.selection(), false) { + self.tool.overlay.cursor = Cursor::NotAllowed; + } return res; } Gesture::Rotate { center, start_angle, ids } => { @@ -1236,7 +1329,9 @@ impl Session { } Ok(()) })?; - self.tool.overlay.cursor = Cursor::Rotate; + // The cursor turns with the pointer about the centre. + self.tool.overlay.cursor = + if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; Ok(()) } Gesture::Draw { start, id, parent, targets } => { @@ -1596,17 +1691,10 @@ impl Session { // Reordering or dragging layers out of an instance (outside its slots), or // moving layers inside one, are edits only its main can make: the gesture is // undone. - let ds = self.ds()?; - let allowed = ds.interaction.as_ref().is_none_or(|it| { - moving.iter().all(|m| { - crate::components::move_allowed(&it.doc, &ds.doc, *m) - && crate::components::refused_override(&it.doc, &ds.doc, *m).is_none() - }) - }); - if allowed { - self.commit_interaction()?; - } else { + if self.refused(&moving, true) { self.cancel_interaction()?; + } else { + self.commit_interaction()?; } } else { self.cancel_interaction()?; @@ -1617,12 +1705,7 @@ impl Session { } Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { // Layers inside an instance keep their main's size and rotation. - let ds = self.ds()?; - let refused = ds - .interaction - .as_ref() - .is_some_and(|it| ds.selection.iter().any(|m| crate::components::refused_override(&it.doc, &ds.doc, *m).is_some())); - if refused { + if self.refused(&self.selection(), false) { self.cancel_interaction()?; } else { self.commit_interaction()?; @@ -1787,6 +1870,10 @@ impl Session { let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); n.transform = pw.inverse() * Affine::translate(at.to_vec2()); n.name = String::new(); + // Readable where it is drawn: white on a dark page (black otherwise, the model's default). + if matches!(d.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark()) { + n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]; + } if let Some((w, h)) = figcraft_text::auto_size(&n) { n.width = w.max(1.0); n.height = h; @@ -2070,8 +2157,10 @@ impl Session { let result = crate::booleans::result_outline(doc, &sel); let focus = self.ds().ok().and_then(crate::DocState::focused); // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. - if self.ds().is_ok_and(|d| d.view.outlines) { - return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result)); + if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { + // What it draws besides the shapes can be clicked too. + let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; + return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); } match focus { Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index face537..99873f4 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -360,7 +360,7 @@ fn the_transform_box_and_its_handles() { for (h, angle) in [(Handle::Corner(0), 45.0), (Handle::Corner(1), 135.0), (Handle::Edge(0), 90.0), (Handle::Edge(1), 180.0)] { assert!(matches!(handle_cursor(&q, h), Cursor::Resize(a) if near(a, angle)), "{h:?}"); } - assert_eq!(handle_cursor(&q, Handle::Rotate(1)), Cursor::Rotate); + assert!(matches!(handle_cursor(&q, Handle::Rotate(1)), Cursor::Rotate(a) if near(a, -45.0)), "turned to the top right corner"); assert_eq!(handle_at(&doc, &[r], Point::new(110.0, -10.0), z), Some(Handle::Rotate(1))); set(&mut s, &[r], json!({"rotation": 30})); let doc = s.doc().unwrap().clone(); diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index b446cf7..1bc9386 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -12,7 +12,7 @@ use super::point_snap; use super::transform::{self, BoxHit, PointBox}; use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, set_state}; use crate::snap::Targets; -use crate::tools::{Cursor, Gesture, Handle, PointerEvent, PointerKind}; +use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind}; use crate::{Result, Session}; /// An in-progress edit-mode gesture (cheap to clone: snap targets are shared). @@ -344,10 +344,16 @@ impl Session { ov.rubber = None; ov.guides.clear(); ov.brush = (ve.tool == VTool::Eraser).then(|| [ev.x, ev.y, ERASER_PX / ev.zoom.max(1e-6)]); - ov.cursor = match ve.tool { - VTool::Move if ev.cmd => Cursor::Crosshair, - VTool::Move => Cursor::Default, - VTool::Pen => Cursor::Pen, + // What a press here would do (see `vector_down`). + ov.cursor = match (ve.tool, h) { + (VTool::Bend, _) => Cursor::Pen(PenCursor::Bend), + (VTool::Move, Some(_)) if ev.cmd => Cursor::Pen(PenCursor::Bend), + (VTool::Move, _) if ev.cmd => Cursor::Crosshair, + (VTool::Move, Some(VHit::Segment { .. })) if ev.alt => Cursor::Pen(PenCursor::Add), + (VTool::Move, _) => Cursor::Default, + (VTool::Pen, Some(VHit::Vertex { .. })) => Cursor::Pen(PenCursor::Close), + (VTool::Pen, Some(VHit::Segment { .. })) => Cursor::Pen(PenCursor::Add), + (VTool::Pen, _) => Cursor::Pen(PenCursor::New), _ => Cursor::Crosshair, }; let w = doc.world_transform(ve.id); @@ -779,7 +785,7 @@ impl Session { } let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle); self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; - self.tool.overlay.cursor = Cursor::Rotate; + self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees()); self.vgesture(VGesture::BoxRotate { b, start_angle, angle }); } VGesture::BendSegment { seg, t } => { diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index f43659e..150f148 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -21,6 +21,8 @@ pub struct Ctx<'a> { /// Device pixels per document unit (for effect radii). pub scale: f64, pub outline_mode: bool, + /// What outline mode draws besides shapes, and its line colour. + pub outline: crate::Outline, /// Layers drawn in outline mode with their contents (outlines for the selection). pub outlined: &'a [NodeId], /// Nodes not to draw (e.g. text being edited draws through the editor). @@ -361,7 +363,16 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep return; } let Some(n) = ctx.doc.node(id) else { return }; - if !n.visible || n.opacity <= 0.0 || n.is_mask { + let outline = ctx.outline_mode || ctx.outlined.contains(&id); + if n.is_mask || n.opacity <= 0.0 && n.visible { + return; + } + if !n.visible { + // Outline mode can show hidden layers too (nothing of them is culled: a hidden layer + // isn't counted in its parent's bounds). + if outline && ctx.outline.hidden { + draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, parent_xf * n.transform, depth); + } return; } let xf = parent_xf * n.transform; @@ -375,7 +386,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep if dev.intersect(view).is_zero_area() && !(dev.width() == 0.0 || dev.height() == 0.0) { return; } - if ctx.outline_mode || ctx.outlined.contains(&id) { + if outline { draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth); return; } @@ -455,12 +466,21 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { + let color = if ctx.outline.light { tiny_skia::Color::WHITE } else { tiny_skia::Color::BLACK }; + let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(color), anti_alias: true, ..Default::default() }; + let stroke = tiny_skia::Stroke { width: 1.0 / ctx.scale.max(1e-6) as f32, ..Default::default() }; if let Some((path, _)) = node_outline(ctx.doc, n) && !matches!(n.kind, NodeKind::Group) && let Some(tp) = ts_path(&path) { - let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::BLACK), anti_alias: true, ..Default::default() }; - let stroke = tiny_skia::Stroke { width: 1.0 / ctx.scale.max(1e-6) as f32, ..Default::default() }; + pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None); + } + // Object bounds: the box of every layer whose outline isn't its box already. + let boxed = matches!(n.kind, NodeKind::Rectangle) && n.corners.uniform() == Some(0.0) || n.kind.is_frame_like(); + if ctx.outline.bounds + && !boxed + && let Some(tp) = ts_path(&figcraft_geom::kurbo::Shape::to_path(&n.local_rect(), 0.1)) + { pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None); } if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.hides_contents() { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1643be1..090a189 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -49,11 +49,24 @@ impl View { } } +/// Outline mode's options: what it draws besides the shapes' outlines. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Outline { + /// Hidden layers are outlined too. + pub hidden: bool, + /// Every layer's box is drawn (groups, text, vectors: where their outline isn't the box). + pub bounds: bool, + /// Light lines, for a dark page (set by the renderer from the page's colour). + pub light: bool, +} + #[derive(Clone, Debug, Default)] pub struct Options { /// Fill the background (None = transparent, e.g. export without page colour). pub background: Option, pub outline_mode: bool, + /// What outline mode draws besides the shapes' outlines. + pub outline: Outline, /// Layers drawn in outline mode (with their contents) whatever `outline_mode` says. pub outlined: Vec, /// Nodes to leave out (an editor drawing them itself). @@ -159,6 +172,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti doc, scale: view.scale, outline_mode: opts.outline_mode, + outline: Outline { light: bg.is_some_and(Color::is_dark), ..opts.outline }, outlined: &opts.outlined, skip: &opts.skip, lod, @@ -185,7 +199,17 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut /// [`render_nodes_into`] `time` seconds into animated GIFs. fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) { - let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None, time }; + let ctx = draw::Ctx { + doc, + scale: view.scale, + outline_mode: false, + outline: Outline::default(), + outlined: &[], + skip: &[], + lod: false, + under: None, + time, + }; for id in ids { draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0); } diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index 31accdd..de8b52b 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -154,7 +154,17 @@ fn exports_never_use_lod() { crate::render_nodes_into(&d, &[card], &v, &mut exact); assert_eq!(png.data(), exact.data()); let mut preview = Pixmap::new(v.width, v.height).unwrap(); - let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, outlined: &[], skip: &[], lod: true, under: None, time: 0.0 }; + let ctx = crate::draw::Ctx { + doc: &d, + scale, + outline_mode: false, + outline: crate::Outline::default(), + outlined: &[], + skip: &[], + lod: true, + under: None, + time: 0.0, + }; let parent = d.world_transform(p); crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0); assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated"); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 85fae0f..bfcf8e3 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -46,7 +46,17 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX)); let (kx, ky) = (pw / cell.size.0, ph / cell.size.1); let mut pix = Pixmap::new(pw as u32, ph as u32)?; - let ctx = Ctx { doc, scale: kx.max(ky) * cell.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None, time }; + let ctx = Ctx { + doc, + scale: kx.max(ky) * cell.scale, + outline_mode: false, + outline: crate::Outline::default(), + outlined: &[], + skip: &[], + lod: false, + under: None, + time, + }; let unrotated = src.transform.inverse(); nested(|| { for (tx, ty) in &cell.tiles { diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index c6780c9..9776b02 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -42,6 +42,7 @@ struct Frame { doc: Arc, page: NodeId, outline: bool, + outline_extras: crate::Outline, outlined: Vec, lod: bool, skip: Vec, @@ -150,6 +151,9 @@ impl Retained { if let Some(last) = self.last.take() && last.page == page && last.outline == opts.outline_mode + && last.outline_extras == opts.outline + // Hidden layers drawn as outlines aren't in the bounds patches are cut from. + && !opts.outline.hidden && last.outlined == opts.outlined && last.lod == opts.lod && last.skip == opts.skip @@ -193,6 +197,7 @@ impl Retained { doc: doc.clone(), page, outline: opts.outline_mode, + outline_extras: opts.outline, outlined: opts.outlined.clone(), lod: opts.lod, skip: opts.skip.clone(), diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 34067f7..b899c87 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1997,8 +1997,11 @@ status = "draft — needs native review" "view.nextPage" = "Página siguiente" "view.nextRegion" = "Enfocar la siguiente región" "view.outlines" = "Contornos" +"view.outlinesHiddenLayers" = "Incluir capas ocultas en los contornos" +"view.outlinesObjectBounds" = "Incluir los límites de los objetos en los contornos" "view.outlinesSelection" = "Contornos de la selección" "view.pan" = "Desplazar la vista" +"view.panelWidths" = "Restablecer el ancho de los paneles" "view.pixelGrid" = "Cuadrícula de píxeles" "view.pixelPreview" = "Vista previa de píxeles" "view.prevFrame" = "Zoom al marco anterior" @@ -2055,6 +2058,8 @@ status = "draft — needs native review" "view.design" = "Diseño" "view.layers" = "Capas" "view.outlines" = "Mostrar contornos" +"view.outlinesHiddenLayers" = "Incluir capas ocultas" +"view.outlinesObjectBounds" = "Incluir límites de objetos" "view.outlinesSelection" = "Mostrar contornos de la selección" "view.prototype" = "Prototipo" "view.themeDark" = "Oscuro" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 589fff7..fdedfea 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{Document, NodeId, NodeKind}; use figcraft_engine::Preedit; -use figcraft_engine::tools::{Cursor, PointerEvent, PointerKind, ToolId, selection_box, selection_frame}; +use figcraft_engine::tools::{PointerEvent, PointerKind, ToolId, selection_box, selection_frame}; use figcraft_geom::Point; use serde_json::json; @@ -28,6 +28,9 @@ pub struct CanvasState { zoom_tool: Option, zoom_drag: Option, pub last_pointer: Option, + /// What the pointer shows over the canvas (`ui.inspect`); none while it is elsewhere or + /// something on the canvas with a cursor of its own has it (a ruler guide, a gradient handle). + pub cursor: Option, preedit: Option, /// The pen pressure last reported during the current press (pen and stylus touches; the /// winit backends and browsers that report it). @@ -45,6 +48,7 @@ impl Default for CanvasState { dragging: false, space_pan: false, middle_pan: None, + cursor: None, zoom_tool: None, zoom_drag: None, last_pointer: None, @@ -99,6 +103,7 @@ struct JobKey { scale: i64, size: (u32, u32), outline: bool, + outline_extras: figcraft_render::Outline, outlined: Vec, skip: Vec, solo: Option, @@ -158,7 +163,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { ds.zoom_to_fit(); } } - input(app, ui, &resp); + let own_cursor = input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); request_render(app, worker, ppp); @@ -220,38 +225,13 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::versions::preview_banner(app, &ctx, rect); crate::branch_ui::banner(app, &ctx, rect); crate::dev_mode::focus_bar(app, &ctx, rect); - // Cursor. - if resp.hovered() { - let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() { - if ctx.input(|i| i.pointer.any_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } - } else if let Some(out) = app.canvas.zoom_tool { - if out { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } - } else { - match app.session.tool.overlay.cursor { - Cursor::Default => egui::CursorIcon::Default, - Cursor::Crosshair | Cursor::Pen => egui::CursorIcon::Crosshair, - Cursor::Move => egui::CursorIcon::Move, - Cursor::Resize(a) => { - let a = ((a % 180.0) + 180.0) % 180.0; - if !(22.5..157.5).contains(&a) { - egui::CursorIcon::ResizeHorizontal - } else if a < 67.5 { - egui::CursorIcon::ResizeNwSe - } else if a < 112.5 { - egui::CursorIcon::ResizeVertical - } else { - egui::CursorIcon::ResizeNeSw - } - } - Cursor::Rotate => egui::CursorIcon::Alias, - Cursor::Grab => egui::CursorIcon::Grab, - Cursor::Grabbing => egui::CursorIcon::Grabbing, - Cursor::Text => egui::CursorIcon::Text, - Cursor::Copy => egui::CursorIcon::Copy, - Cursor::Pointer => egui::CursorIcon::PointingHand, - } - }; - ctx.set_cursor_icon(c); + // The cursor: the tool's, unless something on the canvas with its own has the pointer. + app.canvas.cursor = None; + if let (true, Some(at)) = (own_cursor && resp.hovered(), ctx.input(|i| i.pointer.hover_pos())) { + let panning = app.canvas.space_pan || app.canvas.middle_pan.is_some(); + let shown = crate::cursors::on_canvas(panning, ctx.input(|i| i.pointer.any_down()), app.canvas.zoom_tool, app.session.tool.overlay.cursor); + shown.show(&ctx, at); + app.canvas.cursor = Some(shown); } } @@ -302,6 +282,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let doc = app.preedit_view().map(|p| p.doc.clone()).unwrap_or_else(|| ds.doc.clone()); let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] }; let proof = ds.view.proof_srgb && !doc.color_profile.is_srgb(); + let outline_extras = figcraft_render::Outline { hidden: ds.view.outline_hidden, bounds: ds.view.outline_bounds, light: false }; let key = JobKey { doc: Arc::as_ptr(&doc) as usize, page: ds.page, @@ -309,6 +290,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { scale: (scale * 10000.0) as i64, size: (w, h), outline: ds.view.outlines, + outline_extras, outlined: outlined.clone(), skip: vec![], solo: ds.focused(), @@ -323,6 +305,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { page: ds.page, view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) }, outline: ds.view.outlines, + outline_extras, outlined, skip: vec![], solo: key.solo, @@ -357,9 +340,12 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: true } -fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { +/// The pointer and keys on the canvas. False when something on it with a cursor of its own has +/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor +/// then doesn't show. +fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if crate::proto_canvas::input(app, ui, resp) { - return; + return true; } let ctx = ui.ctx().clone(); let (mods, scroll, zoom_delta, hover_pos, space) = @@ -382,7 +368,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { - return; + return true; }; // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { @@ -406,7 +392,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } } if zoom_tool(app, ui, resp, pos, mods, editing_text || typing_elsewhere) { - return; + return true; } // Middle-button / space panning. let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle)); @@ -425,20 +411,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { if !middle_down && !ctx.input(|i| i.pointer.primary_down()) { app.canvas.middle_pan = None; } - return; + return true; } if !app.canvas.dragging && (resp.hovered() || app.ui.comments.dragging()) && crate::comments::input(app, &ctx, pos) { - return; + return false; } if !app.canvas.dragging && resp.hovered() && crate::guides::input(app, &ctx, pos) { - return; + return false; } if !app.canvas.dragging && resp.hovered() && crate::handles::input(app, &ctx, pos) { - return; + return false; } if resp.secondary_clicked() { open_context_menu(app, pos, mods.command); - return; + return true; } if resp.double_clicked() { let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); @@ -446,7 +432,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { app.toast(e.to_string()); } app.canvas.dragging = false; - return; + return true; } let primary_pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); let primary_down = ctx.input(|i| i.pointer.primary_down()); @@ -494,6 +480,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } app.handle_ui_requests(); } + true } /// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 556031a..37b03e5 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -57,6 +57,7 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: page, view: figcraft_render::View { origin: (0.0, 0.0), scale, width: 2400, height: 2400 }, outline: false, + outline_extras: Default::default(), outlined: vec![], skip: vec![], solo: None, diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs new file mode 100644 index 0000000..1c4e4ad --- /dev/null +++ b/crates/ui-egui/src/cursors.rs @@ -0,0 +1,200 @@ +//! The pointer on the canvas: each engine cursor ([`figcraft_engine::tools::Cursor`]) as one of +//! the system's cursors, or as a small glyph FigCraft draws at the pointer with the system's +//! hidden (a rotate cursor turned to its corner, the pen's states, the eyedropper, the comment +//! pin). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images. + +use egui::{Color32, CursorIcon, Painter, Pos2, Shape, Stroke, pos2, vec2}; +use figcraft_engine::tools::{Cursor, PenCursor}; + +use crate::icons::{self, Icon}; + +/// A cursor FigCraft draws. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Glyph { + /// A curved double arrow about the direction (degrees, 0 = right, 90 = down) from the box's + /// centre to the corner. + Rotate(f32), + Pen(PenCursor), + Eyedropper, + Comment, +} + +/// What the pointer shows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Shown { + System(CursorIcon), + Drawn(Glyph), +} + +/// The system's two-headed arrow nearest a resize along `degrees` (0 = horizontal, 90 = vertical; +/// screen angles, so 45 runs from the top left to the bottom right). +pub fn resize_icon(degrees: f64) -> CursorIcon { + let a = degrees.rem_euclid(180.0); + if !(22.5..157.5).contains(&a) { + CursorIcon::ResizeHorizontal + } else if a < 67.5 { + CursorIcon::ResizeNwSe + } else if a < 112.5 { + CursorIcon::ResizeVertical + } else { + CursorIcon::ResizeNeSw + } +} + +/// What shows for the engine's `cursor`. +pub fn of(cursor: Cursor) -> Shown { + use Shown::{Drawn, System}; + match cursor { + Cursor::Default => System(CursorIcon::Default), + Cursor::Crosshair => System(CursorIcon::Crosshair), + Cursor::Move => System(CursorIcon::Move), + Cursor::Resize(a) if a.is_finite() => System(resize_icon(a)), + Cursor::Resize(_) => System(CursorIcon::Default), + Cursor::Rotate(a) => Drawn(Glyph::Rotate(if a.is_finite() { a.rem_euclid(360.0) as f32 } else { 0.0 })), + Cursor::Grab => System(CursorIcon::Grab), + Cursor::Grabbing => System(CursorIcon::Grabbing), + Cursor::Text => System(CursorIcon::Text), + Cursor::Pen(p) => Drawn(Glyph::Pen(p)), + Cursor::Copy => System(CursorIcon::Copy), + Cursor::Pointer => System(CursorIcon::PointingHand), + Cursor::Eyedropper => Drawn(Glyph::Eyedropper), + Cursor::Comment => Drawn(Glyph::Comment), + Cursor::NotAllowed => System(CursorIcon::NotAllowed), + } +} + +/// What shows on the canvas: panning (Space or the middle button: the open hand, closed while a +/// button is down) and the zoom tool (Z; `Some(true)`: Alt, zooming out) come before the tool's +/// cursor. +pub fn on_canvas(panning: bool, button_down: bool, zoom_out: Option, cursor: Cursor) -> Shown { + match (panning, zoom_out) { + (true, _) => Shown::System(if button_down { CursorIcon::Grabbing } else { CursorIcon::Grab }), + (false, Some(out)) => Shown::System(if out { CursorIcon::ZoomOut } else { CursorIcon::ZoomIn }), + (false, None) => of(cursor), + } +} + +impl Shown { + /// The system cursor to ask for (none under a drawn one). + pub fn icon(self) -> CursorIcon { + match self { + Shown::System(i) => i, + Shown::Drawn(_) => CursorIcon::None, + } + } + + /// A name for it (`ui.inspect`): the CSS cursor names where there is one ("default", + /// "nwse-resize", "copy"), and "rotate" (with its angle), "pen", "pen-close", "pen-add", + /// "pen-bend", "eyedropper" and "comment" for the drawn ones. + pub fn name(self) -> String { + match self { + Shown::System(i) => match i { + CursorIcon::Default => "default", + CursorIcon::Crosshair => "crosshair", + CursorIcon::Move => "move", + CursorIcon::ResizeHorizontal => "ew-resize", + CursorIcon::ResizeVertical => "ns-resize", + CursorIcon::ResizeNwSe => "nwse-resize", + CursorIcon::ResizeNeSw => "nesw-resize", + CursorIcon::Grab => "grab", + CursorIcon::Grabbing => "grabbing", + CursorIcon::Text => "text", + CursorIcon::Copy => "copy", + CursorIcon::PointingHand => "pointer", + CursorIcon::NotAllowed => "not-allowed", + CursorIcon::ZoomIn => "zoom-in", + CursorIcon::ZoomOut => "zoom-out", + _ => "other", + } + .to_string(), + Shown::Drawn(Glyph::Rotate(a)) => format!("rotate {}", a.round()), + Shown::Drawn(Glyph::Pen(PenCursor::New)) => "pen".into(), + Shown::Drawn(Glyph::Pen(PenCursor::Close)) => "pen-close".into(), + Shown::Drawn(Glyph::Pen(PenCursor::Add)) => "pen-add".into(), + Shown::Drawn(Glyph::Pen(PenCursor::Bend)) => "pen-bend".into(), + Shown::Drawn(Glyph::Eyedropper) => "eyedropper".into(), + Shown::Drawn(Glyph::Comment) => "comment".into(), + } + } + + /// Ask for the system cursor and draw the glyph, if it is one, at `at` over everything else. + pub fn show(self, ctx: &egui::Context, at: Pos2) { + ctx.set_cursor_icon(self.icon()); + if let Shown::Drawn(g) = self { + paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas_cursor"))), at, g); + } + } +} + +/// Dark lines over a light edge: readable on any page and artwork. +const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E); +const EDGE: Color32 = Color32::WHITE; + +/// A line through `pts` with the light edge under it. +fn line(painter: &Painter, pts: &[Pos2]) { + painter.add(Shape::line(pts.to_vec(), Stroke::new(3.5, EDGE))); + painter.add(Shape::line(pts.to_vec(), Stroke::new(1.5, INK))); +} + +/// `icon` in a 16 px box at `min`, with the light edge around it. +fn icon(painter: &Painter, min: Pos2, icon: Icon) { + let r = egui::Rect::from_min_size(min, vec2(16.0, 16.0)); + for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] { + icons::paint(painter, r.translate(d), icon, EDGE); + } + icons::paint(painter, r, icon, INK); +} + +/// Points of the arc of radius `r` about `c` from angle `a0` to `a1` (radians). +fn arc(c: Pos2, r: f32, a0: f32, a1: f32) -> Vec { + (0..=12).map(|i| a0 + (a1 - a0) * i as f32 / 12.0).map(|a| c + vec2(a.cos(), a.sin()) * r).collect() +} + +/// Draw `glyph` with its hot spot at `at`. +pub fn paint(painter: &Painter, at: Pos2, glyph: Glyph) { + match glyph { + Glyph::Rotate(degrees) => { + // An arc bulging away from the box, through the pointer, with an arrowhead at each end. + let (dir, r, half) = (degrees.to_radians(), 11.0, 0.8); + let c = at - vec2(dir.cos(), dir.sin()) * r; + let pts = arc(c, r, dir - half, dir + half); + line(painter, &pts); + for (end, sign) in [(dir - half, -1.0), (dir + half, 1.0)] { + let tip = c + vec2(end.cos(), end.sin()) * r; + // Along the arc at its end, pointing onwards. + let t = vec2(-end.sin(), end.cos()) * sign; + let n = vec2(end.cos(), end.sin()); + line(painter, &[tip - t * 4.5 + n * 3.5, tip + t * 1.0, tip - t * 4.5 - n * 3.5]); + } + } + Glyph::Pen(state) => { + // A cross open at the centre (the point it lands on stays visible), and what a click + // does at its lower right. + for (a, b) in [ + (vec2(-8.0, 0.0), vec2(-2.5, 0.0)), + (vec2(2.5, 0.0), vec2(8.0, 0.0)), + (vec2(0.0, -8.0), vec2(0.0, -2.5)), + (vec2(0.0, 2.5), vec2(0.0, 8.0)), + ] { + line(painter, &[at + a, at + b]); + } + let b = at + vec2(10.0, 10.0); + match state { + PenCursor::New => {} + PenCursor::Close => { + let mut ring = arc(b, 3.0, 0.0, std::f32::consts::TAU); + ring.push(pos2(b.x + 3.0, b.y)); + line(painter, &ring); + } + PenCursor::Add => { + line(painter, &[b - vec2(3.5, 0.0), b + vec2(3.5, 0.0)]); + line(painter, &[b - vec2(0.0, 3.5), b + vec2(0.0, 3.5)]); + } + PenCursor::Bend => line(painter, &arc(b + vec2(-3.5, 3.5), 7.0, -std::f32::consts::FRAC_PI_2, 0.0)), + } + } + // The dropper's tip and the pin's tail, at the bottom left of their icons, are the hot spots. + Glyph::Eyedropper => icon(painter, at - vec2(2.5, 13.5), Icon::Eyedropper), + Glyph::Comment => icon(painter, at - vec2(2.5, 13.5), Icon::Comment), + } +} diff --git a/crates/ui-egui/src/cursors_tests.rs b/crates/ui-egui/src/cursors_tests.rs new file mode 100644 index 0000000..94109dc --- /dev/null +++ b/crates/ui-egui/src/cursors_tests.rs @@ -0,0 +1,201 @@ +//! The cursor the canvas shows: the translation of every engine cursor into a system cursor or a +//! glyph drawn at the pointer, and (driven through egui headless) what the window asks for with +//! each tool, while panning or zooming, over a ruler guide, and over a layers row that takes no +//! drop. + +use egui::{CursorIcon, Event, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use figcraft_engine::tools::{Cursor, PenCursor, ToolId}; +use serde_json::json; + +use crate::cursors::{Glyph, Shown, of, on_canvas, resize_icon}; +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +#[test] +fn every_engine_cursor_has_a_system_cursor_or_a_glyph() { + use Shown::{Drawn, System}; + // (engine cursor, what shows, its name in `ui.inspect`) + let rows: Vec<(Cursor, Shown, &str)> = vec![ + (Cursor::Default, System(CursorIcon::Default), "default"), + (Cursor::Crosshair, System(CursorIcon::Crosshair), "crosshair"), + (Cursor::Move, System(CursorIcon::Move), "move"), + (Cursor::Resize(0.0), System(CursorIcon::ResizeHorizontal), "ew-resize"), + (Cursor::Resize(90.0), System(CursorIcon::ResizeVertical), "ns-resize"), + (Cursor::Resize(45.0), System(CursorIcon::ResizeNwSe), "nwse-resize"), + (Cursor::Resize(135.0), System(CursorIcon::ResizeNeSw), "nesw-resize"), + (Cursor::Rotate(45.0), Drawn(Glyph::Rotate(45.0)), "rotate 45"), + (Cursor::Rotate(-45.0), Drawn(Glyph::Rotate(315.0)), "rotate 315"), + (Cursor::Grab, System(CursorIcon::Grab), "grab"), + (Cursor::Grabbing, System(CursorIcon::Grabbing), "grabbing"), + (Cursor::Text, System(CursorIcon::Text), "text"), + (Cursor::Pen(PenCursor::New), Drawn(Glyph::Pen(PenCursor::New)), "pen"), + (Cursor::Pen(PenCursor::Close), Drawn(Glyph::Pen(PenCursor::Close)), "pen-close"), + (Cursor::Pen(PenCursor::Add), Drawn(Glyph::Pen(PenCursor::Add)), "pen-add"), + (Cursor::Pen(PenCursor::Bend), Drawn(Glyph::Pen(PenCursor::Bend)), "pen-bend"), + (Cursor::Copy, System(CursorIcon::Copy), "copy"), + (Cursor::Pointer, System(CursorIcon::PointingHand), "pointer"), + (Cursor::Eyedropper, Drawn(Glyph::Eyedropper), "eyedropper"), + (Cursor::Comment, Drawn(Glyph::Comment), "comment"), + (Cursor::NotAllowed, System(CursorIcon::NotAllowed), "not-allowed"), + ]; + for (cursor, shown, name) in rows { + assert_eq!(of(cursor), shown, "{cursor:?}"); + assert_eq!(shown.name(), name, "{cursor:?}"); + // A drawn cursor hides the system's; a system cursor is never "other". + assert_eq!(shown.icon() == CursorIcon::None, matches!(shown, Drawn(_)), "{cursor:?}"); + assert_ne!(name, "other"); + } + // Every tool's own cursor is in the table (none falls through to a cursor without a name). + for t in ToolId::ALL { + assert_ne!(of(t.cursor()).name(), "other", "{t:?}"); + } + // Junk angles don't break it. + assert_eq!(of(Cursor::Resize(f64::NAN)), System(CursorIcon::Default)); + assert_eq!(of(Cursor::Rotate(f64::INFINITY)), Drawn(Glyph::Rotate(0.0))); +} + +#[test] +fn resize_arrows_follow_the_angle_in_eighths() { + // (degrees, the two-headed arrow): each covers 45° about its own direction, any turn. + let rows = [ + (0.0, CursorIcon::ResizeHorizontal), + (22.0, CursorIcon::ResizeHorizontal), + (23.0, CursorIcon::ResizeNwSe), + (67.0, CursorIcon::ResizeNwSe), + (68.0, CursorIcon::ResizeVertical), + (112.0, CursorIcon::ResizeVertical), + (113.0, CursorIcon::ResizeNeSw), + (157.0, CursorIcon::ResizeNeSw), + (158.0, CursorIcon::ResizeHorizontal), + (180.0, CursorIcon::ResizeHorizontal), + (225.0, CursorIcon::ResizeNwSe), + (-45.0, CursorIcon::ResizeNeSw), + (-90.0, CursorIcon::ResizeVertical), + (720.0 + 30.0, CursorIcon::ResizeNwSe), + ]; + for (a, want) in rows { + assert_eq!(resize_icon(a), want, "{a}°"); + } +} + +#[test] +fn panning_and_the_zoom_tool_come_before_the_tools_cursor() { + use Shown::System; + // (panning, a button down, zoom tool, tool cursor) → shown + let rows = [ + ((true, false, None, Cursor::Crosshair), System(CursorIcon::Grab)), + ((true, true, None, Cursor::Crosshair), System(CursorIcon::Grabbing)), + ((true, true, Some(false), Cursor::Default), System(CursorIcon::Grabbing)), + ((false, false, Some(false), Cursor::Crosshair), System(CursorIcon::ZoomIn)), + ((false, true, Some(true), Cursor::Default), System(CursorIcon::ZoomOut)), + ((false, true, None, Cursor::Copy), System(CursorIcon::Copy)), + ]; + for ((pan, down, zoom, cursor), want) in rows { + assert_eq!(on_canvas(pan, down, zoom, cursor), want, "pan {pan}, down {down}, zoom {zoom:?}, {cursor:?}"); + } +} + +/// A frame with the pointer at `p` and `keys` held; the cursor the window asked for and the +/// canvas's own. +fn at(u: &mut Ui, p: Pos2, keys: &[Key], modifiers: Modifiers) -> (CursorIcon, Option) { + let mut events = vec![Event::PointerMoved(p)]; + events.extend(keys.iter().map(|key| Event::Key { key: *key, physical_key: None, pressed: true, repeat: false, modifiers })); + u.frame_with(events, modifiers); + // Once more, settled (the tool sees the pointer where it is). + let out = u.frame_with(vec![Event::PointerMoved(p)], modifiers); + let shown = u.app.canvas.cursor.map(Shown::name); + let released: Vec = + keys.iter().map(|key| Event::Key { key: *key, physical_key: None, pressed: false, repeat: false, modifiers }).collect(); + u.frame_with(released, modifiers); + (out.platform_output.cursor_icon, shown) +} + +#[test] +fn the_window_asks_for_the_cursor_of_the_tool_under_the_pointer() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let rect = u.app.session.doc().unwrap().world_bounds(r).unwrap(); + let on = u.app.to_screen(rect.center().x, rect.center().y); + let edge = u.app.to_screen(rect.x1, rect.center().y); + let empty = u.app.to_screen(rect.x1 + 80.0 / u.app.session.ds().unwrap().view.zoom, rect.y1); + let no = Modifiers::NONE; + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + // (tool, where, keys held, modifiers) → (system cursor, the canvas's cursor by name) + type Row<'a> = (&'a str, Pos2, &'a [Key], Modifiers, CursorIcon, &'a str); + let rows: Vec = vec![ + ("move", empty, &[], no, CursorIcon::Default, "default"), + ("move", edge, &[], no, CursorIcon::ResizeHorizontal, "ew-resize"), + ("move", on, &[], alt, CursorIcon::Copy, "copy"), + ("hand", empty, &[], no, CursorIcon::Grab, "grab"), + ("rectangle", empty, &[], no, CursorIcon::Crosshair, "crosshair"), + ("frame", on, &[], no, CursorIcon::Crosshair, "crosshair"), + ("slice", empty, &[], no, CursorIcon::Crosshair, "crosshair"), + ("text", empty, &[], no, CursorIcon::Crosshair, "crosshair"), + ("pen", empty, &[], no, CursorIcon::None, "pen"), + ("comment", empty, &[], no, CursorIcon::None, "comment"), + ("move", empty, &[Key::Space], no, CursorIcon::Grab, "grab"), + ("rectangle", empty, &[Key::Space], no, CursorIcon::Grab, "grab"), + ("move", empty, &[Key::Z], no, CursorIcon::ZoomIn, "zoom-in"), + ("move", empty, &[Key::Z], alt, CursorIcon::ZoomOut, "zoom-out"), + ]; + for (tool, p, keys, mods, icon, name) in rows { + u.app.run("tool.set", json!({"tool": tool})); + let (got, shown) = at(&mut u, p, keys, mods); + assert_eq!((got, shown.as_deref()), (icon, Some(name)), "{tool} with {keys:?} {mods:?}"); + } + // `ui.inspect` reports it while the pointer is over the canvas, and nothing over a panel. + u.app.run("edit.pickColor", json!({})); + u.frame(vec![Event::PointerMoved(empty)]); + u.frame(vec![Event::PointerMoved(empty)]); + assert_eq!(u.app.inspect()["cursor"], "eyedropper"); + u.frame(vec![Event::PointerMoved(pos2(100.0, 300.0))]); + assert_eq!(u.app.inspect()["cursor"], json!(null), "over the layers panel"); +} + +#[test] +fn a_ruler_guide_keeps_its_own_cursor_under_the_tools() { + let mut s = Session::new(); + s.execute("view.rulers", &json!({"on": true})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "offset": 40})).unwrap(); + let mut u = Ui::new(s); + let c = u.app.canvas.rect.center(); + let on_guide = pos2(u.app.to_screen(40.0, 0.0).x, c.y); + let away = pos2(on_guide.x + 120.0, c.y); + u.frame(vec![Event::PointerMoved(on_guide)]); + let out = u.frame_output(vec![Event::PointerMoved(on_guide)]); + assert_eq!(out.platform_output.cursor_icon, CursorIcon::ResizeHorizontal, "over a guide: the guide's arrows"); + assert!(u.app.canvas.cursor.is_none(), "the tool's cursor stays out of it"); + u.frame(vec![Event::PointerMoved(away)]); + let out = u.frame_output(vec![Event::PointerMoved(away)]); + assert_eq!(out.platform_output.cursor_icon, CursorIcon::Default); + assert_eq!(u.app.canvas.cursor.map(Shown::name).as_deref(), Some("default")); +} + +#[test] +fn a_layers_row_that_takes_no_drop_shows_not_allowed() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let child = id(s.execute("node.create", &json!({"type": "ellipse", "parent": frame, "x": 10, "y": 10, "width": 10, "height": 10})).unwrap()); + let other = id(s.execute("node.create", &json!({"type": "rectangle", "x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.insert(frame); + u.frame(vec![]); + let (f, c, o) = (u.row_at(frame), u.row_at(child), u.row_at(other)); + // The frame dragged over its own child: no drop there. + u.drag_to(f, c); + let out = u.frame_output(vec![Event::PointerMoved(c)]); + assert_eq!(out.platform_output.cursor_icon, CursorIcon::NotAllowed); + // Over another layer's row: the grabbing hand of a drag that can land. + let out = u.frame_output(vec![Event::PointerMoved(o)]); + assert_eq!(out.platform_output.cursor_icon, CursorIcon::Grabbing); + u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + u.button(o, false); +} diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index a7697d9..917ab3f 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -207,6 +207,7 @@ fn a_finished_background_render_wakes_the_ui() { page: ds.page, view: figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 120 }, outline: false, + outline_extras: Default::default(), outlined: vec![], skip: vec![], solo: None, diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index eec2595..956709f 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -661,6 +661,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // A layer can't go into itself or its own descendants. let blocked = |id: NodeId| moving.contains(&id) || doc.ancestors(id).iter().any(|a| moving.contains(a)); let mut drop: Option<(NodeId, i8)> = None; + // The drag is over a row that takes no drop: the not-allowed cursor shows. + let mut refused = false; // Only the rows in view are laid out and drawn; the rest is empty scroll space. egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let pitch = ROW + ui.spacing().item_spacing.y; @@ -847,6 +849,11 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.drag_started() { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } + let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); + // What is inside the dragged layers, and inside an instance, takes no drop. + refused |= over + && (doc.ancestors(row.id).iter().any(|a| moving.contains(a)) + || n.parent.is_some_and(|p| figcraft_engine::components::structure_locked(&doc, p))); if dragging.is_some() && !blocked(row.id) && let Some(p) = ui.input(|i| i.pointer.hover_pos()) @@ -917,6 +924,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { }; let galley = crate::bidi::name_galley(ui.painter(), &label, regular(11.0), t.text, 240.0); crate::widgets::drag_ghost(ui.ctx(), p, galley); + // Over a row that takes no drop (the dragged layers themselves and what is in them). + if refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + } } if let Some(sw) = sweep { let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down())); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 142fc38..aff5587 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod comments; pub mod component_panel; pub mod control; pub mod credits; +pub mod cursors; pub mod dev_compare; pub mod dev_mode; pub mod dialogs; @@ -70,6 +71,8 @@ mod canvas_fixes_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] +mod cursors_tests; +#[cfg(test)] mod dev_mode_tests; #[cfg(test)] mod frame_label_tests; @@ -102,6 +105,8 @@ mod live_text_tests; #[cfg(test)] mod panel_reorder_tests; #[cfg(test)] +mod panel_width_tests; +#[cfg(test)] mod perf_tests; #[cfg(test)] mod plugin_panel_tests; @@ -124,6 +129,8 @@ mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; #[cfg(test)] +mod text_enter_tests; +#[cfg(test)] mod titlebar_tests; #[cfg(test)] mod vector_tail_ui_tests; @@ -228,8 +235,30 @@ pub(crate) fn repaint_in(ctx: &egui::Context, secs: f64) { } } +/// The side panels' widths: the least and most a drag of their edge gives, what a double-click on +/// the edge puts back, and how much of the window they leave the canvas. +pub const LEFT_PANEL: std::ops::RangeInclusive = 200.0..=480.0; +pub const RIGHT_PANEL: std::ops::RangeInclusive = 240.0..=480.0; +pub const PANEL_DEFAULT: f32 = 240.0; +pub const CANVAS_MIN: f32 = 160.0; + +/// The widths the side panels are drawn at in `avail` points (the window less the icon rail), from +/// the widths asked for (`left`: none while it is closed): each within its limits, and narrower +/// in a small window so the canvas keeps [`CANVAS_MIN`] (the right panel gives way last). +pub fn fit_panels(avail: f32, left: Option, right: f32) -> (f32, f32) { + let fit = |w: f32, range: &std::ops::RangeInclusive, room: f32| { + let w = if w.is_finite() { w } else { PANEL_DEFAULT }; + w.clamp(*range.start(), room.clamp(*range.start(), *range.end())) + }; + let left_min = left.map_or(0.0, |_| *LEFT_PANEL.start()); + let right = fit(right, &RIGHT_PANEL, avail - left_min - CANVAS_MIN); + (left.map_or(0.0, |l| fit(l, &LEFT_PANEL, avail - right - CANVAS_MIN)), right) +} + /// UI-only state (serialisable for `ui.inspect`). pub struct UiState { + /// The widths asked for the side panels (a drag of their edge; [`fit_panels`] gives what a + /// small window draws). pub left_width: f32, pub right_width: f32, pub right_tab: RightTab, @@ -346,8 +375,8 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { Self { - left_width: 240.0, - right_width: 240.0, + left_width: PANEL_DEFAULT, + right_width: PANEL_DEFAULT, right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, @@ -436,6 +465,8 @@ pub struct FigApp { language: Option, /// A field had the keyboard when the last frame ended. field_focus: bool, + /// A panel's width changed and isn't kept in the preferences yet (its edge is being dragged). + panels_unsaved: bool, /// Control-channel requests waiting for the next frame. pub control_queue: Vec, /// Input events from the control channel (`ui.input`) for the next frame's raw input. @@ -455,9 +486,11 @@ pub type Done = Box; impl FigApp { pub fn new(session: Session, services: Box) -> Self { + // The main window's panels open as wide as they were left. + let (left_width, right_width) = session.prefs.panel_widths; Self { + ui: UiState { left_width, right_width, ..UiState::default() }, session, - ui: UiState::default(), canvas: canvas::CanvasState::default(), services, worker: None, @@ -466,6 +499,7 @@ impl FigApp { cjk_checked: None, language: None, field_focus: false, + panels_unsaved: false, control_queue: Vec::new(), control_input: Vec::new(), frame_stats: frame_stats::FrameStats::default(), @@ -591,6 +625,16 @@ impl FigApp { self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect }; } "panel.leftSidebar" => self.ui.left_collapsed = !self.ui.left_collapsed, + "panel.widths" => { + let width = |k: &str| r.data.get(k).and_then(Value::as_f64).map(|w| w as f32); + let (left, right) = (width("left"), width("right")); + let reset = left.is_none() && right.is_none(); + self.set_panel_widths( + left.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.left_width }), + right.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.right_width }), + ); + self.services.save_prefs(&self.session.prefs); + } "focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32), "panel.layers" => self.ui.left_tab = LeftTab::File, "panel.assets" => self.ui.left_tab = LeftTab::Assets, @@ -675,6 +719,42 @@ impl FigApp { } } + /// Set the widths asked for this window's side panels (within their limits); the main + /// window's are kept in the preferences. + pub fn set_panel_widths(&mut self, left: f32, right: f32) { + let within = |w: f32, range: std::ops::RangeInclusive| if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { PANEL_DEFAULT }; + (self.ui.left_width, self.ui.right_width) = (within(left, LEFT_PANEL), within(right, RIGHT_PANEL)); + if self.windows.current == 0 { + self.session.prefs.panel_widths = (self.ui.left_width, self.ui.right_width); + } + } + + /// The edge between a side panel and the canvas, at `x` down `rows`: dragging it sizes the + /// panel (`grows`: +1 for the left panel, whose edge is on its right, −1 for the right one), a + /// double-click puts back the default width. Returns the width asked for, when it changed. + fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option { + let grab = ui.style().interaction.resize_grab_radius_side; + let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows); + let id = egui::Id::new(("panel_edge", name)); + let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::Tokens::get(ui.ctx()).accent)); + } + if resp.double_clicked() { + return Some(PANEL_DEFAULT); + } + // The edge stays under the pointer where it was grabbed (also back from past a limit). + let at = resp.interact_pointer_pos().filter(|_| resp.dragged())?; + if resp.drag_started() { + let pressed = ui.input(|i| i.pointer.press_origin()).unwrap_or(at); + ui.data_mut(|d| d.insert_temp(id, pressed.x - x)); + } + let grabbed: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); + let w = width + (at.x - grabbed - x) * grows; + (w != width).then_some(w) + } + pub fn save_as_dialog(&mut self) { let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); if let Some(p) = self.services.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"]) { @@ -779,6 +859,8 @@ impl FigApp { _ => false, }; self.ui.dark = dark; + // New files and pages take the theme's page colour (existing pages keep theirs). + self.session.set_ui_dark(dark); theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion()); if self.worker.is_none() { self.worker = Some(render_worker::Worker::new(ctx.clone())); @@ -893,11 +975,16 @@ impl FigApp { layers::rail(self, ui) }); } - if !hidden && !self.ui.left_collapsed { + // The panels are as wide as asked, less in a window too small for them and the canvas. + let left_open = !hidden && !self.ui.left_collapsed; + let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), self.ui.right_width); + let rows = ui.available_rect_before_wrap().y_range(); + let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new(); + if left_open { + edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0)); egui::Panel::left("left_panel") - .resizable(true) - .default_size(self.ui.left_width) - .size_range(200.0..=480.0) + .resizable(false) + .exact_size(left_w) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| { let label = match self.ui.left_tab { @@ -910,10 +997,10 @@ impl FigApp { }); } if !hidden { + edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); egui::Panel::right("right_panel") - .resizable(true) - .default_size(self.ui.right_width) - .size_range(240.0..=480.0) + .resizable(false) + .exact_size(right_w) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| { let label = match self.ui.right_tab { @@ -937,6 +1024,19 @@ impl FigApp { }); } }); + // The panels' edges, over the canvas and the panels (drawn after them: they take the press). + for (name, x, shown, grows) in edges { + if let Some(w) = Self::panel_edge(ui, name, x, rows, shown, grows) { + let (l, r) = if name == "left" { (w, self.ui.right_width) } else { (self.ui.left_width, w) }; + self.set_panel_widths(l, r); + self.panels_unsaved = true; + } + } + // Kept once the drag is over (not on every pointer move). + if self.panels_unsaved && !ctx.input(|i| i.pointer.any_down()) { + self.panels_unsaved = false; + self.services.save_prefs(&self.session.prefs); + } // Before the shortcuts: keys the hovered pane's prototype uses don't reach the editor, and // the arrows step through comment threads with the Comment tool. present::show_pane(self, &ctx); @@ -1046,6 +1146,8 @@ impl FigApp { "window": self.windows.current, "windows": windows::list(self), "tool": self.session.tool_info(), + "cursor": self.canvas.cursor.map(cursors::Shown::name), + "panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed}, "status": self.session.status(), "lastError": self.ui.last_error, }) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f4891e8..b7e0552 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -194,7 +194,8 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { } // One window in the browser. let enabled = available(m.id) && !(m.id == "window.new" && ui.ctx().embed_viewports()); - if item(ui, &i18n::menu_item(m.id, m.label), crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled { + let checked = figcraft_engine::cmd::view::checked(&app.session, m.id).unwrap_or(false); + if item(ui, &i18n::menu_item(m.id, m.label), crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, checked, &t) && enabled { clicked = Some(m.id); } } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) { diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs new file mode 100644 index 0000000..a5a84d0 --- /dev/null +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -0,0 +1,230 @@ +//! The side panels' widths, driven through egui headless: dragging a panel's edge sizes it within +//! its limits, a double-click puts back the default, the widths are kept in the preferences (when +//! the drag ends) and come back with them, the view's centre stays where it was, Minimize UI +//! hides the panels without losing their widths, and a window at its smallest size still has a +//! canvas. + +use std::sync::{Arc, Mutex}; + +use egui::{Event, Pos2, RawInput, Rect, pos2, vec2}; +use figcraft_engine::{Prefs, Session}; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::{CANVAS_MIN, FigApp, LEFT_PANEL, NoServices, PANEL_DEFAULT, RIGHT_PANEL, Services, fit_panels}; + +/// The panel widths in the preferences each time they were kept. +type Kept = Arc>>; + +/// Services that note the preferences each time they are kept. +struct Keeps(Kept); +impl Services for Keeps { + fn save_prefs(&mut self, prefs: &Prefs) { + self.0.lock().unwrap().push(prefs.panel_widths); + } +} + +fn ui() -> (Ui, Kept) { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + let mut u = Ui::new(s); + let kept = Kept::default(); + u.app.services = Box::new(Keeps(kept.clone())); + u.frame(vec![]); + (u, kept) +} + +/// The x of the left panel's edge (the canvas's left) and of the right panel's (its right). +fn edges(u: &Ui) -> (f32, f32) { + (u.app.canvas.rect.min.x, u.app.canvas.rect.max.x) +} + +fn widths(u: &Ui) -> (f32, f32) { + (u.app.ui.left_width, u.app.ui.right_width) +} + +/// Press on `from`, move to `to` in steps and let go. +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); +} + +fn double_click(u: &mut Ui, p: Pos2) { + // Well after any earlier click (a third quick click isn't a double-click). + for _ in 0..40 { + u.frame(vec![]); + } + for _ in 0..2 { + u.button(p, true); + u.button(p, false); + } + u.frame(vec![]); +} + +#[test] +fn panels_fit_the_window() { + let (l0, l1, r0, r1) = (*LEFT_PANEL.start(), *LEFT_PANEL.end(), *RIGHT_PANEL.start(), *RIGHT_PANEL.end()); + // (room beside the icon rail, left asked (none: closed), right asked) → (left, right) drawn + let rows = [ + ((1384.0, Some(240.0), 240.0), (240.0, 240.0), "as asked in a wide window"), + ((1384.0, Some(100.0), 100.0), (l0, r0), "never narrower than their least"), + ((1384.0, Some(900.0), 900.0), (l1, r1), "never wider than their most"), + ((1384.0, None, 300.0), (0.0, 300.0), "the left panel closed"), + ((744.0, Some(240.0), 240.0), (240.0, 240.0), "the smallest window with the default widths"), + ((744.0, Some(480.0), 480.0), (l0, 744.0 - l0 - CANVAS_MIN), "the smallest window: the left panel gives way first, then the right"), + ((744.0, Some(480.0), 240.0), (744.0 - 240.0 - CANVAS_MIN, 240.0), "the left panel gives the canvas its room"), + ((744.0, None, 480.0), (0.0, 480.0), "with the left panel closed the right one has room"), + ((300.0, Some(240.0), 240.0), (l0, r0), "a window smaller than any layout: the least widths"), + ((1384.0, Some(f32::NAN), f32::INFINITY), (PANEL_DEFAULT, PANEL_DEFAULT), "junk widths: the default"), + ]; + for ((avail, left, right), want, why) in rows { + assert_eq!(fit_panels(avail, left, right), want, "{why}"); + } + // Whatever is asked, a window of the smallest size or more keeps its canvas. + for avail in [744.0, 900.0, 1384.0] { + for left in [None, Some(l0), Some(300.0), Some(l1)] { + for right in [r0, 333.0, r1] { + let (l, r) = fit_panels(avail, left, right); + assert!(avail - l - r >= CANVAS_MIN, "{avail} {left:?} {right}: canvas {}", avail - l - r); + } + } + } +} + +#[test] +fn dragging_an_edge_sizes_its_panel_within_its_limits_and_keeps_the_views_centre() { + let (mut u, kept) = ui(); + assert_eq!(widths(&u), (PANEL_DEFAULT, PANEL_DEFAULT)); + let (left, right) = edges(&u); + let centre = { + let v = &u.app.session.ds().unwrap().view; + (v.cx, v.cy, v.zoom) + }; + // The left panel's edge, 60 to the right: 60 wider, and the canvas starts 60 further on. + drag(&mut u, pos2(left, 400.0), pos2(left + 60.0, 400.0)); + assert_eq!(widths(&u), (300.0, 240.0)); + assert_eq!(edges(&u), (left + 60.0, right)); + // The right panel's edge, 40 to the left: 40 wider. + drag(&mut u, pos2(right, 400.0), pos2(right - 40.0, 400.0)); + assert_eq!(widths(&u), (300.0, 280.0)); + assert_eq!(edges(&u), (left + 60.0, right - 40.0)); + // The view keeps its centre and zoom: the page doesn't jump, the canvas is just narrower. + let v = u.app.session.ds().unwrap().view.clone(); + assert_eq!((v.cx, v.cy, v.zoom), centre); + assert_eq!(v.vw, f64::from(right - left - 100.0)); + // Kept in the preferences, once per drag (not on every move). + assert_eq!(u.app.session.prefs.panel_widths, (300.0, 280.0)); + assert_eq!(*kept.lock().unwrap(), [(300.0, 240.0), (300.0, 280.0)]); + // Past the limits the edge stops, and comes back with the pointer. + let (left, right) = edges(&u); + drag(&mut u, pos2(left, 300.0), pos2(left + 700.0, 300.0)); + assert_eq!(widths(&u).0, *LEFT_PANEL.end()); + let (left, _) = edges(&u); + drag(&mut u, pos2(left, 300.0), pos2(20.0, 300.0)); + assert_eq!(widths(&u).0, *LEFT_PANEL.start()); + drag(&mut u, pos2(right, 300.0), pos2(right + 500.0, 300.0)); + assert_eq!(widths(&u).1, *RIGHT_PANEL.start()); + let (_, right) = edges(&u); + u.drag_to(pos2(right, 300.0), pos2(right - 900.0, 300.0)); + assert_eq!(widths(&u).1, *RIGHT_PANEL.end(), "at its most while the pointer is past it"); + u.frame(vec![Event::PointerMoved(pos2(right - 100.0, 300.0))]); + assert_eq!(widths(&u).1, *RIGHT_PANEL.start() + 100.0, "back with the pointer"); + u.button(pos2(right - 100.0, 300.0), false); +} + +#[test] +fn a_double_click_on_an_edge_puts_back_the_default_width() { + let (mut u, kept) = ui(); + let (left, right) = edges(&u); + drag(&mut u, pos2(left, 400.0), pos2(left + 90.0, 400.0)); + drag(&mut u, pos2(right, 400.0), pos2(right - 90.0, 400.0)); + assert_eq!(widths(&u), (330.0, 330.0)); + let (left, right) = edges(&u); + double_click(&mut u, pos2(left, 500.0)); + assert_eq!(widths(&u), (PANEL_DEFAULT, 330.0), "the left panel alone"); + double_click(&mut u, pos2(right, 500.0)); + assert_eq!(widths(&u), (PANEL_DEFAULT, PANEL_DEFAULT)); + assert_eq!(kept.lock().unwrap().last(), Some(&(PANEL_DEFAULT, PANEL_DEFAULT)), "kept"); + // A click on the canvas beside the edge is the canvas's. + let (left, _) = edges(&u); + drag(&mut u, pos2(left + 40.0, 500.0), pos2(left + 90.0, 520.0)); + assert_eq!(widths(&u), (PANEL_DEFAULT, PANEL_DEFAULT)); +} + +#[test] +fn the_command_sets_and_resets_the_widths() { + let (mut u, kept) = ui(); + u.app.run("view.panelWidths", json!({"left": 320, "right": 400})); + u.frame(vec![]); + assert_eq!(widths(&u), (320.0, 400.0)); + assert_eq!(u.app.inspect()["panels"], json!({"left": 320.0, "right": 400.0, "leftCollapsed": false})); + u.app.run("view.panelWidths", json!({"left": 5000})); + assert_eq!(widths(&u), (*LEFT_PANEL.end(), 400.0), "within its limits; the other panel as it was"); + for junk in [json!({"left": "wide"}), json!({"left": null, "right": null}), json!({})] { + u.app.run("view.panelWidths", junk.clone()); + assert_eq!(widths(&u), (PANEL_DEFAULT, PANEL_DEFAULT), "{junk}: both back to the default"); + u.app.run("view.panelWidths", json!({"left": 300, "right": 300})); + } + assert_eq!(kept.lock().unwrap().last(), Some(&(300.0, 300.0))); +} + +#[test] +fn the_widths_come_back_with_the_preferences() { + let mut s = Session::new(); + s.prefs.panel_widths = (360.0, 300.0); + let mut u = Ui::new(s); + u.frame(vec![]); + assert_eq!(widths(&u), (360.0, 300.0)); + let (left, right) = edges(&u); + assert_eq!(right - left, 1440.0 - 56.0 - 360.0 - 300.0, "the canvas between them (the icon rail is 56 wide)"); + // Widths from a damaged file are brought within the limits when drawn. + let mut s = Session::new(); + s.prefs.panel_widths = (-5.0, f32::NAN); + let mut u = Ui::new(s); + u.frame(vec![]); + let (left, right) = edges(&u); + assert_eq!((left, 1440.0 - right), (56.0 + *LEFT_PANEL.start(), PANEL_DEFAULT)); +} + +#[test] +fn minimize_ui_hides_the_panels_and_keeps_their_widths() { + let (mut u, _) = ui(); + u.app.run("view.panelWidths", json!({"left": 320, "right": 400})); + u.frame(vec![]); + let shown = edges(&u); + u.app.run("view.toggleUi", json!({})); + u.frame(vec![]); + assert_eq!(edges(&u), (0.0, 1440.0), "the canvas has the window"); + // No edge to drag where the panels were. + drag(&mut u, pos2(shown.0, 400.0), pos2(shown.0 + 80.0, 400.0)); + assert_eq!(widths(&u), (320.0, 400.0)); + u.app.run("view.toggleUi", json!({})); + u.frame(vec![]); + assert_eq!((edges(&u), widths(&u)), (shown, (320.0, 400.0)), "back as they were"); + // The left sidebar alone: its edge goes with it, the right panel stays. + u.app.run("view.leftSidebar", json!({})); + u.frame(vec![]); + assert_eq!(edges(&u), (56.0, shown.1)); +} + +#[test] +fn the_smallest_window_keeps_its_canvas_whatever_the_widths() { + for asked in [(240.0, 240.0), (480.0, 480.0), (480.0, 240.0), (200.0, 480.0)] { + let mut s = Session::new(); + s.prefs.panel_widths = asked; + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(NoServices)); + // The desktop window's minimum inner size. + let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 500.0)); + for k in 0..5 { + let input = RawInput { screen_rect: Some(screen), time: Some(f64::from(k) / 60.0), ..Default::default() }; + ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + } + let canvas = app.canvas.rect; + assert!(canvas.width() >= CANVAS_MIN, "{asked:?}: the canvas is {} wide", canvas.width()); + assert!(screen.contains_rect(canvas), "{asked:?}: the canvas {canvas:?} is in the window"); + assert!(canvas.height() > 300.0, "{asked:?}: and has its height"); + assert_eq!((app.ui.left_width, app.ui.right_width), asked, "the widths asked for come back in a wider window"); + } +} diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 5357eb1..12a0ba6 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -16,6 +16,8 @@ pub struct Job { pub page: NodeId, pub view: View, pub outline: bool, + /// Outline mode's options (hidden layers, object bounds). + pub outline_extras: figcraft_render::Outline, /// Layers drawn as outlines (outlines for the selection). pub outlined: Vec, pub skip: Vec, @@ -62,6 +64,7 @@ fn run(job: &Job, retained: &mut Retained) -> Option { // The canvas is a preview: what's too small to see in detail at this zoom is approximated. let opts = figcraft_render::Options { outline_mode: job.outline, + outline: job.outline_extras, outlined: job.outlined.clone(), skip: job.skip.clone(), solo: job.solo, diff --git a/crates/ui-egui/src/text_enter_tests.rs b/crates/ui-egui/src/text_enter_tests.rs new file mode 100644 index 0000000..2a0f3d2 --- /dev/null +++ b/crates/ui-egui/src/text_enter_tests.rs @@ -0,0 +1,79 @@ +//! Enter and Escape on text, and the page a new file gets in each theme, driven through egui +//! headless: Enter on a selected text layer edits it with all of its text selected (what a +//! double-click into it does), typing replaces it, and Escape leaves editing with the layer still +//! selected; File ▸ New in the dark theme makes a dark page. + +use egui::{Event, Key, Modifiers}; +use figcraft_color::Color; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn press(u: &mut Ui, key: Key) { + let modifiers = Modifiers::NONE; + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn text(u: &Ui, id: NodeId) -> String { + match &u.app.session.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + } +} + +fn page_colour(u: &Ui) -> Color { + let ds = u.app.session.ds().unwrap(); + match ds.doc.node(ds.page).unwrap().kind { + NodeKind::Page { background, .. } => background, + _ => panic!("not a page"), + } +} + +#[test] +fn enter_edits_a_selected_text_with_all_of_it_selected_and_escape_keeps_the_layer_selected() { + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "Hello world", "x": 20, "y": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let mut u = Ui::new(s); + press(&mut u, Key::Enter); + let te = u.app.session.ds().unwrap().text_edit.clone().expect("Enter starts editing"); + assert_eq!((te.id, te.anchor.min(te.caret), te.anchor.max(te.caret)), (t, 0, 11), "all of the text is selected"); + // Typing replaces the selection, as after a double-click into the text. + u.frame(vec![Event::Text("Bye".into())]); + assert_eq!(text(&u, t), "Bye"); + press(&mut u, Key::Escape); + let ds = u.app.session.ds().unwrap(); + assert!(ds.text_edit.is_none(), "Escape leaves editing"); + assert_eq!(ds.selection, vec![t], "the layer stays selected"); + assert_eq!(text(&u, t), "Bye", "what was typed is kept"); + // One undo step for the whole edit. + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "Hello world"); + // A second Escape deselects, as it does for any layer. + press(&mut u, Key::Escape); + assert!(u.app.session.selection().is_empty()); +} + +#[test] +fn a_new_file_takes_the_page_colour_of_the_theme_and_keeps_it() { + let mut s = Session::new(); + s.prefs.theme = "dark".into(); + let mut u = Ui::new(s); + assert_eq!(page_colour(&u), Color::DARK_PAGE, "the file the app opened with, untouched"); + u.app.run("file.new", json!({})); + assert_eq!(page_colour(&u), Color::DARK_PAGE); + u.app.run("view.themeLight", json!({})); + u.frame(vec![]); + assert_eq!(page_colour(&u), Color::DARK_PAGE, "a change of theme leaves the page alone"); + u.app.run("file.new", json!({})); + assert_eq!(page_colour(&u), Color::DEFAULT_PAGE, "a file made in the light theme"); + u.app.run("view.themeDark", json!({})); + u.frame(vec![]); + u.app.run("page.add", json!({})); + assert_eq!(page_colour(&u), Color::DARK_PAGE, "a page added in the dark theme"); +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index cf754b8..2c233fc 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -208,7 +208,15 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option, path: Option< docs: vec![uid], active: Some(uid), pane: Pane { - ui: UiState { title_bar: app.ui.title_bar, right_tab: app.ui.right_tab, left_tab: app.ui.left_tab, ..UiState::default() }, + // A new window opens like the one it comes from: the same tabs and panel widths. + ui: UiState { + title_bar: app.ui.title_bar, + right_tab: app.ui.right_tab, + left_tab: app.ui.left_tab, + left_width: app.ui.left_width, + right_width: app.ui.right_width, + ..UiState::default() + }, ..Pane::default() }, pos: at.or_else(|| outer.map(|r| r.min + Vec2::splat(32.0))),