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:
parent
c5830e4683
commit
485715f11e
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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('/')) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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))),
|
||||
|
|
|
|||
Loading…
Reference in New Issue