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.
This commit is contained in:
bflatastic 2026-10-12 23:19:53 +07:00 committed by BFlat
parent c5830e4683
commit 485715f11e
31 changed files with 1823 additions and 112 deletions

View File

@ -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);

View File

@ -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<NodeId> {
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<NodeId> {
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<NodeId> {
///
/// `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<NodeId> {
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<NodeId> {
/// 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<OutlineHits>) -> Option<NodeId> {
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;

View File

@ -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"),

View File

@ -15,7 +15,15 @@ pub fn specs() -> Vec<CommandSpec> {
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}))
}),

View File

@ -26,9 +26,13 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
pub fn specs() -> Vec<CommandSpec> {
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}));
}

View File

@ -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<bool> {
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<Value> {
let scale = match p.get("scale") {
@ -214,6 +240,20 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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",

View File

@ -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<Row> = 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<Row> = 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}");
}
}

View File

@ -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<UiRequest>,
/// 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<bool>,
/// 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;

View File

@ -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<NodeId>, &'static str);
let rows: Vec<Row> = 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");
}
}

View File

@ -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<String> {
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)]);
}

View File

@ -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::<f64>() / 4.0, c.iter().map(|p| p.y).sum::<f64>() / 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)),

View File

@ -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();

View File

@ -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 } => {

View File

@ -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() {

View File

@ -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<Color>,
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<NodeId>,
/// 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);
}

View File

@ -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");

View File

@ -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 {

View File

@ -42,6 +42,7 @@ struct Frame {
doc: Arc<Document>,
page: NodeId,
outline: bool,
outline_extras: crate::Outline,
outlined: Vec<NodeId>,
lod: bool,
skip: Vec<NodeId>,
@ -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(),

View File

@ -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"

View File

@ -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<bool>,
zoom_drag: Option<Pos2>,
pub last_pointer: Option<Point>,
/// 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<crate::cursors::Shown>,
preedit: Option<PreeditView>,
/// 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<NodeId>,
skip: Vec<NodeId>,
solo: Option<NodeId>,
@ -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

View File

@ -57,6 +57,7 @@ fn big_job(doc: &Arc<figcraft_doc::Document>, 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,

View File

@ -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<bool>, 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<Pos2> {
(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),
}
}

View File

@ -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<String>) {
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<Event> =
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<Row> = 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);
}

View File

@ -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,

View File

@ -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()));

View File

@ -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<f32> = 200.0..=480.0;
pub const RIGHT_PANEL: std::ops::RangeInclusive<f32> = 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<f32>, right: f32) -> (f32, f32) {
let fit = |w: f32, range: &std::ops::RangeInclusive<f32>, 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<String>,
/// 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<ControlRequest>,
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
@ -455,9 +486,11 @@ pub type Done = Box<dyn FnOnce(&mut FigApp, &egui::Context, Value)>;
impl FigApp {
pub fn new(session: Session, services: Box<dyn Services>) -> 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<f32>| 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<f32> {
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,
})

View File

@ -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('/')) {

View File

@ -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<Mutex<Vec<(f32, f32)>>>;
/// 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");
}
}

View File

@ -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<NodeId>,
pub skip: Vec<NodeId>,
@ -62,6 +64,7 @@ fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
// 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,

View File

@ -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");
}

View File

@ -208,7 +208,15 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, 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))),