diff --git a/ROADMAP.md b/ROADMAP.md index 684b5572..ce6e6d29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -213,7 +213,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Area | Weight | Score | Remaining work | Hours | |---|---|---|---|---| -| Canvas & navigation | 6 | 73% | frame guides | 2–4 | +| Canvas & navigation | 6 | 75% | rulers and guides on rotated frames, visual QA at extreme zooms | 1–2 | | Selection & transform | 8 | 68% | measure hover polish, scale tool on-canvas handles | 5–8 | | Layers panel | 5 | 79% | keyboard row navigation | 1–2 | | Shapes & vector | 8 | 74% | live boolean editing, arc handles on canvas, shape builder on strokes and open paths, one-sided widths | 4–8 | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 56e0b05f..982e55b9 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -366,6 +366,9 @@ pub struct Node { pub mask_type: MaskType, #[serde(default, skip_serializing_if = "is_default")] pub grids: Vec, + /// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]). + #[serde(default, skip_serializing_if = "is_default")] + pub guides: Vec, #[serde(default, skip_serializing_if = "is_default")] pub exports: Vec, #[serde(default, skip_serializing_if = "is_default")] @@ -495,6 +498,7 @@ impl Node { is_mask: false, mask_type: MaskType::Alpha, grids: Vec::new(), + guides: Vec::new(), exports: Vec::new(), interactions: Vec::new(), overflow: Default::default(), @@ -525,6 +529,21 @@ impl Node { matches!(self.kind, NodeKind::Page { .. }) } + /// The ruler guides on this page or frame, in its own coordinates. + pub fn guides(&self) -> &[Guide] { + match &self.kind { + NodeKind::Page { guides, .. } => guides, + _ => &self.guides, + } + } + + pub fn guides_mut(&mut self) -> &mut Vec { + match &mut self.kind { + NodeKind::Page { guides, .. } => guides, + _ => &mut self.guides, + } + } + /// Whether painted strokes are present. pub fn has_visible_stroke(&self) -> bool { self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0 diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 12e6433a..74acac7a 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{bad, spec}; +use crate::{Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -107,52 +107,56 @@ pub fn specs() -> Vec { .collect(), )) }), - spec!("guide.add", "Add guide", "{vertical (bool), offset, page?} → {index}", true, |s, p| { - let id = id(p, "page").unwrap_or(s.page()?); + spec!("guide.add", "Add guide", "{vertical (bool), offset, frame? | page?} → {index}", true, |s, p| { + let owner = guide_owner(s, "guide.add", p)?; let offset = req_num("guide.add", p, "offset")?.clamp(-1e7, 1e7); let g = Guide { vertical: boolean(p, "vertical").unwrap_or(true), offset }; - match &mut s.doc_mut()?.get_mut(id)?.kind { - NodeKind::Page { guides, .. } if guides.len() < 10_000 => { - guides.push(g); - Ok(json!({"index": guides.len() - 1})) - } - NodeKind::Page { .. } => Err(bad("guide.add", "too many guides")), - _ => Err(bad("guide.add", "`page` must be a page")), + let guides = s.doc_mut()?.get_mut(owner)?.guides_mut(); + if guides.len() >= 10_000 { + return Err(bad("guide.add", "too many guides")); } + guides.push(g); + Ok(json!({"index": guides.len() - 1})) }), - spec!("guide.move", "Move guide", "{index, offset, page?}", true, |s, p| { - let id = id(p, "page").unwrap_or(s.page()?); + spec!("guide.move", "Move guide", "{index, offset, frame? | page?}", true, |s, p| { + let owner = guide_owner(s, "guide.move", p)?; let k = guide_index("guide.move", p)?; let offset = req_num("guide.move", p, "offset")?.clamp(-1e7, 1e7); - match &mut s.doc_mut()?.get_mut(id)?.kind { - NodeKind::Page { guides, .. } => { - guides.get_mut(k).ok_or_else(|| bad("guide.move", "no such guide"))?.offset = offset; - ok() - } - _ => Err(bad("guide.move", "`page` must be a page")), - } + s.doc_mut()?.get_mut(owner)?.guides_mut().get_mut(k).ok_or_else(|| bad("guide.move", "no such guide"))?.offset = offset; + ok() }), - spec!("guide.remove", "Remove guide", "{index, page?}", true, |s, p| { - let id = id(p, "page").unwrap_or(s.page()?); + spec!("guide.remove", "Remove guide", "{index, frame? | page?}", true, |s, p| { + let owner = guide_owner(s, "guide.remove", p)?; let k = guide_index("guide.remove", p)?; - match &mut s.doc_mut()?.get_mut(id)?.kind { - NodeKind::Page { guides, .. } if k < guides.len() => { - guides.remove(k); - ok() - } - _ => Err(bad("guide.remove", "no such guide")), + let guides = s.doc_mut()?.get_mut(owner)?.guides_mut(); + if k >= guides.len() { + return Err(bad("guide.remove", "no such guide")); } + guides.remove(k); + ok() }), - spec!("guide.clear", "Remove all guides", "{page?}", true, |s, p| { - let id = id(p, "page").unwrap_or(s.page()?); - if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind { - guides.clear(); - } + spec!("guide.clear", "Remove all guides", "{frame? | page?}", true, |s, p| { + let owner = guide_owner(s, "guide.clear", p)?; + s.doc_mut()?.get_mut(owner)?.guides_mut().clear(); ok() }), ] } +/// Whose guides a command edits: `frame` (in its own coordinates), else `page`, else the current +/// page. +fn guide_owner(s: &Session, cmd: &str, p: &Value) -> crate::Result { + let owner = match id(p, "frame") { + Some(f) => f, + None => id(p, "page").unwrap_or(s.page()?), + }; + match s.doc()?.node(owner) { + Some(n) if n.is_page() || n.kind.is_frame_like() => Ok(owner), + Some(_) => Err(bad(cmd, "guides go on a page or a frame")), + None => Err(bad(cmd, "no such page or frame")), + } +} + /// A guide index from params (never negative). fn guide_index(cmd: &str, p: &Value) -> crate::Result { req_index(cmd, p, "index") diff --git a/crates/engine/src/frame_guides_tests.rs b/crates/engine/src/frame_guides_tests.rs new file mode 100644 index 00000000..7f12161c --- /dev/null +++ b/crates/engine/src/frame_guides_tests.rs @@ -0,0 +1,97 @@ +//! Frame guides: guides kept on a frame in its own coordinates, moving with it, saved with the file, +//! copied with the frame, and snapped to by the layers inside it. + +use figcraft_doc::NodeId; +use figcraft_geom::Rect; +use serde_json::json; + +use crate::Session; +use crate::snap::Targets; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn guides(s: &Session, owner: NodeId) -> Vec<(bool, f64)> { + s.doc().unwrap().node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect() +} + +/// A frame at (100, 50) with a child rectangle. +fn setup() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let rect = id(s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 20, "height": 20})).unwrap()); + (s, frame, rect) +} + +#[test] +fn guides_on_a_frame_are_kept_in_its_coordinates() { + let (mut s, frame, _) = setup(); + let r = s.execute("guide.add", &json!({"frame": frame, "vertical": true, "offset": 40})).unwrap(); + assert_eq!(r["index"], json!(0)); + s.execute("guide.add", &json!({"frame": frame, "vertical": false, "offset": 25.5})).unwrap(); + assert_eq!(guides(&s, frame), vec![(true, 40.0), (false, 25.5)]); + // The page's guides are separate. + let page = s.page().unwrap(); + assert!(guides(&s, page).is_empty()); + s.execute("guide.move", &json!({"frame": frame, "index": 0, "offset": 60})).unwrap(); + assert_eq!(guides(&s, frame)[0], (true, 60.0)); + s.undo().unwrap(); + assert_eq!(guides(&s, frame)[0], (true, 40.0)); + s.execute("guide.remove", &json!({"frame": frame, "index": 1})).unwrap(); + assert_eq!(guides(&s, frame), vec![(true, 40.0)]); + s.execute("guide.clear", &json!({"frame": frame})).unwrap(); + assert!(guides(&s, frame).is_empty()); +} + +#[test] +fn only_pages_and_frames_take_guides() { + let (mut s, frame, rect) = setup(); + for bad in [ + json!({"frame": rect, "offset": 5}), + json!({"frame": 999_999, "offset": 5}), + json!({"frame": frame, "offset": "x"}), + json!({"page": rect, "offset": 1}), + ] { + assert!(s.execute("guide.add", &bad).is_err(), "{bad}"); + } + assert!(s.execute("guide.move", &json!({"frame": frame, "index": 0, "offset": 1})).is_err()); + assert!(s.execute("guide.remove", &json!({"frame": frame, "index": 0})).is_err()); + assert!(guides(&s, frame).is_empty()); +} + +#[test] +fn frame_guides_move_with_the_frame_and_snap_its_layers() { + let (mut s, frame, rect) = setup(); + s.execute("guide.add", &json!({"frame": frame, "vertical": true, "offset": 40})).unwrap(); + let page = s.page().unwrap(); + // The guide is at page x 140: a box inside the frame whose left edge is 1 past it snaps back. + let snap = |s: &Session| { + let t = Targets::gather(s.doc().unwrap(), page, &[rect]); + t.snap_rect(Rect::new(141.0, 300.0, 149.0, 310.0), 4.0).0 + }; + assert_eq!(snap(&s), -1.0); + // Moving the frame moves its guide (still 40 in the frame). + s.execute("node.set", &json!({"ids": [frame], "props": {"x": 200}})).unwrap(); + assert_eq!(guides(&s, frame), vec![(true, 40.0)]); + let t = Targets::gather(s.doc().unwrap(), page, &[rect]); + assert_eq!(t.snap_rect(Rect::new(241.0, 300.0, 249.0, 310.0), 4.0).0, -1.0); + // Layers outside the frame don't snap to it. + let outside = id(s.execute("node.create", &json!({"type": "rectangle", "x": 1000, "y": 1000, "width": 5, "height": 5})).unwrap()); + let t = Targets::gather(s.doc().unwrap(), page, &[outside]); + assert_eq!(t.snap_rect(Rect::new(241.0, 1000.0, 249.0, 1010.0), 4.0).0, 0.0); +} + +#[test] +fn frame_guides_are_saved_and_copied_with_the_frame() { + let (mut s, frame, _) = setup(); + s.execute("guide.add", &json!({"frame": frame, "vertical": false, "offset": 12})).unwrap(); + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(doc.node(frame).unwrap().guides.iter().map(|g| g.offset).collect::>(), vec![12.0]); + s.execute("select.set", &json!({"ids": [frame]})).unwrap(); + s.execute("edit.duplicate", &json!({})).unwrap(); + let copy = *s.ds().unwrap().selection.first().unwrap(); + assert_ne!(copy, frame); + assert_eq!(guides(&s, copy), vec![(false, 12.0)]); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bdb4a024..5dc638f4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -885,6 +885,8 @@ mod devinspect_tests; #[cfg(test)] mod effects_tests; #[cfg(test)] +mod frame_guides_tests; +#[cfg(test)] mod frame_tests; #[cfg(test)] mod html_codegen_tests; diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 71cb245f..353081b1 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -88,6 +88,23 @@ impl Targets { } } } + // The guides of the frames the moving layers are in, across each frame. + let mut f = parent; + for _ in 0..128 { + let Some(n) = doc.node(f).filter(|_| f != page) else { break }; + if let Some(b) = doc.world_bounds(f).filter(|_| !n.guides.is_empty()) { + let xf = doc.world_transform(f); + for g in &n.guides { + if g.vertical { + t.xs.push(((xf * Point::new(g.offset, 0.0)).x, b.y0, b.y1)); + } else { + t.ys.push(((xf * Point::new(0.0, g.offset)).y, b.x0, b.x1)); + } + } + } + let Some(up) = n.parent else { break }; + f = up; + } t } diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index ea82f3ce..1f2d1979 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -1,8 +1,11 @@ //! Ruler guides on the canvas: drag out of a ruler to add one, drag a guide to move it, drag it -//! back onto its ruler to remove it. Guides show while the rulers do; layers snap to them. +//! back onto its ruler to remove it. A guide dropped on a frame belongs to that frame: it's kept in +//! the frame's coordinates, moves with it and spans only the frame. Guides show while the rulers +//! do; layers snap to them. use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, pos2, vec2}; -use figcraft_doc::{Guide, NodeKind}; +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::Point; use serde_json::json; use crate::FigApp; @@ -16,25 +19,107 @@ const GUIDE: Color32 = Color32::from_rgb(0xF2, 0x48, 0x22); enum GuideDrag { /// Dragging a new guide out of a ruler. New { vertical: bool }, - /// Moving guide `index`. - Move { index: usize, vertical: bool }, + /// Moving guide `index` of `owner` (a page or frame). + Move { owner: NodeId, index: usize, vertical: bool }, +} + +/// A guide where it lands on the page. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Placed { + /// The page or frame it belongs to. + owner: NodeId, + index: usize, + vertical: bool, + /// Its offset in the owner's coordinates (what its label shows). + offset: f64, + /// Its page position (x for vertical guides, y for horizontal). + at: f64, + /// A frame guide's extent across it on the page (y span for vertical guides); None: the page. + span: Option<(f64, f64)>, } fn drag_id() -> Id { Id::new("guide_drag") } -/// The dragged guide's live position (page units). +/// The dragged guide as it would land. fn preview_id() -> Id { Id::new("guide_preview") } -fn page_guides(app: &FigApp) -> Vec { +/// The page's guides and those of the frames on it (frames last, so they're found first). +fn placed_guides(app: &FigApp) -> Vec { let Ok(ds) = app.session.ds() else { return vec![] }; - match ds.doc.node(ds.page).map(|n| &n.kind) { - Some(NodeKind::Page { guides, .. }) => guides.clone(), - _ => vec![], + let doc = &ds.doc; + let mut out: Vec = doc + .node(ds.page) + .map(|n| n.guides()) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(index, g)| Placed { owner: ds.page, index, vertical: g.vertical, offset: g.offset, at: g.offset, span: None }) + .collect(); + let mut stack: Vec<(NodeId, usize)> = doc.node(ds.page).map(|n| n.children.iter().map(|c| (*c, 0)).collect()).unwrap_or_default(); + while let Some((id, depth)) = stack.pop() { + let Some(n) = doc.node(id).filter(|n| n.visible && depth < 64) else { continue }; + if !n.guides.is_empty() { + for (index, g) in n.guides.iter().enumerate() { + if let Some(p) = place(doc, id, index, g.vertical, g.offset) { + out.push(p); + } + } + } + stack.extend(n.children.iter().map(|c| (*c, depth + 1))); } + out +} + +/// Where guide `index` of frame `owner` at `offset` (frame coordinates) lands on the page. +fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f64) -> Option { + let b = doc.world_bounds(owner)?; + let xf = doc.world_transform(owner); + let (at, span) = if vertical { ((xf * Point::new(offset, 0.0)).x, (b.y0, b.y1)) } else { ((xf * Point::new(0.0, offset)).y, (b.x0, b.x1)) }; + Some(Placed { owner, index, vertical, offset, at, span: Some(span) }) +} + +/// A guide dropped at page point `p`: on the frame under it (in the frame's coordinates) or on the +/// page. +fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option { + let ds = app.session.ds().ok()?; + let round = |v: f64| if snap { v.round() } else { (v * 100.0).round() / 100.0 }; + let frame = figcraft_engine::cmd::create::frame_at(&ds.doc, ds.page, p, &[]).filter(|f| ds.doc.node(*f).is_some_and(|n| n.kind.is_frame_like())); + match frame { + Some(f) => { + let local = ds.doc.world_transform(f).inverse() * p; + place(&ds.doc, f, usize::MAX, vertical, round(if vertical { local.x } else { local.y })) + } + None => { + let at = round(if vertical { p.x } else { p.y }); + Some(Placed { owner: ds.page, index: usize::MAX, vertical, offset: at, at, span: None }) + } + } +} + +/// Where the moved guide of `owner` lands with the pointer at page point `p`. +fn moved(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option { + let ds = app.session.ds().ok()?; + let round = |v: f64| if snap { v.round() } else { (v * 100.0).round() / 100.0 }; + if owner == ds.page { + let at = round(if vertical { p.x } else { p.y }); + return Some(Placed { owner, index, vertical, offset: at, at, span: None }); + } + let local = ds.doc.world_transform(owner).inverse() * p; + place(&ds.doc, owner, index, vertical, round(if vertical { local.x } else { local.y })) +} + +/// A guide command's params: frame guides name their frame, page guides need no owner. +fn owned(owner: NodeId, page: Option, mut params: serde_json::Value) -> serde_json::Value { + if Some(owner) != page + && let Some(o) = params.as_object_mut() + { + o.insert("frame".into(), json!(owner)); + } + params } fn rulers_on(app: &FigApp) -> bool { @@ -42,18 +127,30 @@ fn rulers_on(app: &FigApp) -> bool { } /// Screen coordinate of a guide (x for vertical guides, y for horizontal). -fn screen_of(app: &FigApp, g: &Guide) -> f32 { - if g.vertical { app.to_screen(g.offset, 0.0).x } else { app.to_screen(0.0, g.offset).y } +fn screen_of(app: &FigApp, g: &Placed) -> f32 { + if g.vertical { app.to_screen(g.at, 0.0).x } else { app.to_screen(0.0, g.at).y } } -/// The guide within a few points of `pos` (top-most first). -fn guide_at(app: &FigApp, pos: Pos2) -> Option<(usize, bool)> { - page_guides(app) - .iter() - .enumerate() - .rev() - .find(|(_, g)| (screen_of(app, g) - if g.vertical { pos.x } else { pos.y }).abs() <= 3.0) - .map(|(i, g)| (i, g.vertical)) +/// The guide's line on screen, clipped to the canvas `r`. +fn segment(app: &FigApp, g: &Placed, r: Rect) -> [Pos2; 2] { + let s = screen_of(app, g); + let (a, b) = match g.span { + Some((a, b)) if g.vertical => (app.to_screen(0.0, a).y.max(r.min.y), app.to_screen(0.0, b).y.min(r.max.y)), + Some((a, b)) => (app.to_screen(a, 0.0).x.max(r.min.x), app.to_screen(b, 0.0).x.min(r.max.x)), + None if g.vertical => (r.min.y, r.max.y), + None => (r.min.x, r.max.x), + }; + if g.vertical { [pos2(s, a), pos2(s, b)] } else { [pos2(a, s), pos2(b, s)] } +} + +/// The guide within a few points of `pos` (frame guides and later ones first). +fn guide_at(app: &FigApp, pos: Pos2) -> Option { + let r = app.canvas.rect; + placed_guides(app).into_iter().rev().find(|g| { + let [a, b] = segment(app, g, r); + let (across, along, lo, hi) = if g.vertical { (pos.x - a.x, pos.y, a.y, b.y) } else { (pos.y - a.y, pos.x, a.x, b.x) }; + across.abs() <= 3.0 && along >= lo - 3.0 && along <= hi + 3.0 + }) } fn cursor(vertical: bool) -> egui::CursorIcon { @@ -74,28 +171,36 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { }; let p = app.to_page(pos); let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true); - let raw = if vertical { p.x } else { p.y }; - let offset = if snap { raw.round() } else { (raw * 100.0).round() / 100.0 }; - ctx.data_mut(|d| d.insert_temp(preview_id(), (vertical, offset))); + let at = match drag { + GuideDrag::New { vertical } => drop_target(app, p, vertical, snap), + GuideDrag::Move { owner, index, vertical } => moved(app, owner, index, vertical, p, snap), + }; + ctx.data_mut(|d| match at { + Some(g) => { + d.insert_temp(preview_id(), g); + } + None => d.remove::(preview_id()), + }); ctx.set_cursor_icon(cursor(vertical)); if released { ctx.data_mut(|d| { d.remove::(drag_id()); - d.remove::<(bool, f64)>(preview_id()); + d.remove::(preview_id()); }); // Released over its own ruler: no guide (a moved one is removed). let on_ruler = if vertical { pos.x < r.min.x + RULER } else { pos.y < r.min.y + RULER }; - match (drag, on_ruler) { - (GuideDrag::New { .. }, false) => { - app.run("guide.add", json!({"vertical": vertical, "offset": offset})); + let page = app.session.ds().map(|d| d.page).ok(); + match (drag, on_ruler, at) { + (GuideDrag::New { .. }, false, Some(g)) => { + app.run("guide.add", owned(g.owner, page, json!({"vertical": vertical, "offset": g.offset}))); } - (GuideDrag::Move { index, .. }, true) => { - app.run("guide.remove", json!({"index": index})); + (GuideDrag::Move { owner, index, .. }, true, _) => { + app.run("guide.remove", owned(owner, page, json!({"index": index}))); } - (GuideDrag::Move { index, .. }, false) => { - app.run("guide.move", json!({"index": index, "offset": offset})); + (GuideDrag::Move { owner, index, .. }, false, Some(g)) => { + app.run("guide.move", owned(owner, page, json!({"index": index, "offset": g.offset}))); } - (GuideDrag::New { .. }, true) => {} + _ => {} } } ctx.request_repaint(); @@ -116,17 +221,18 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { // Existing guides move with the Move tool (not while editing text or a vector). let idle = app.session.tool.tool == figcraft_engine::ToolId::Move && app.session.ds().is_ok_and(|d| d.text_edit.is_none() && d.vector_edit.is_none()); - if idle && let Some((index, vertical)) = guide_at(app, pos) { - ctx.set_cursor_icon(cursor(vertical)); + if idle && let Some(g) = guide_at(app, pos) { + ctx.set_cursor_icon(cursor(g.vertical)); if pressed { - ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::Move { index, vertical })); + ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical })); } return true; } false } -/// Draw the page's guides (under the rulers) and the one being dragged with its position. +/// Draw the page's and its frames' guides (under the rulers) and the one being dragged with its +/// position. pub fn draw(app: &FigApp, painter: &egui::Painter) { if !rulers_on(app) { return; @@ -135,41 +241,33 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let ctx = painter.ctx(); let drag = ctx.data(|d| d.get_temp::(drag_id())); let moving = match drag { - Some(GuideDrag::Move { index, .. }) => Some(index), + Some(GuideDrag::Move { owner, index, .. }) => Some((owner, index)), _ => None, }; - let hover = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)).and_then(|p| guide_at(app, p)).map(|(i, _)| i); - let line = |painter: &egui::Painter, vertical: bool, s: f32, width: f32| { - if vertical { - painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], Stroke::new(width, GUIDE)); - } else { - painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], Stroke::new(width, GUIDE)); - } - }; - for (i, g) in page_guides(app).iter().enumerate() { - if Some(i) == moving { + let hover = if drag.is_none() { ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)).and_then(|p| guide_at(app, p)) } else { None }; + for g in placed_guides(app) { + if Some((g.owner, g.index)) == moving { continue; } - let s = screen_of(app, g); - line(painter, g.vertical, s, if hover == Some(i) && drag.is_none() { 2.0 } else { 1.0 }); - if hover == Some(i) && drag.is_none() { - label(painter, r, g.vertical, s, g.offset); + let hovered = hover == Some(g); + painter.line_segment(segment(app, &g, r), Stroke::new(if hovered { 2.0 } else { 1.0 }, GUIDE)); + if hovered { + label(app, painter, r, &g); } } - if let Some((vertical, offset)) = ctx.data(|d| d.get_temp::<(bool, f64)>(preview_id())) { - let g = Guide { vertical, offset }; - let s = screen_of(app, &g); - line(painter, vertical, s, 1.0); - label(painter, r, vertical, s, offset); + if let Some(g) = ctx.data(|d| d.get_temp::(preview_id())) { + painter.line_segment(segment(app, &g, r), Stroke::new(1.0, GUIDE)); + label(app, painter, r, &g); } } -/// The guide's position, in a tag next to its ruler. -fn label(painter: &egui::Painter, r: Rect, vertical: bool, s: f32, offset: f64) { - let text = crate::canvas::fmt(offset); - let galley = painter.layout_no_wrap(text, semibold(10.0), Color32::WHITE); +/// The guide's offset (in its page's or frame's coordinates), in a tag next to its ruler or at the +/// start of a frame guide. +fn label(app: &FigApp, painter: &egui::Painter, r: Rect, g: &Placed) { + let galley = painter.layout_no_wrap(crate::canvas::fmt(g.offset), semibold(10.0), Color32::WHITE); let size = galley.size() + vec2(8.0, 4.0); - let at = if vertical { pos2(s + 4.0, r.min.y + RULER + 4.0) } else { pos2(r.min.x + RULER + 4.0, s + 4.0) }; + let [a, _] = segment(app, g, r); + let at = if g.vertical { pos2(a.x + 4.0, a.y.max(r.min.y + RULER) + 4.0) } else { pos2(a.x.max(r.min.x + RULER) + 4.0, a.y + 4.0) }; let tag = Rect::from_min_size(at, size); painter.rect_filled(tag, CornerRadius::same(3), GUIDE); painter.galley(tag.min + vec2(4.0, 2.0), galley, Color32::WHITE); diff --git a/docs/mcp.md b/docs/mcp.md index 5a9abdcc..3bf5a1d7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -329,6 +329,14 @@ closing or saving a version ends the preview first, and `version.list` marks the has unsaved changes since its last version; the app runs it every 30 minutes for each open document, and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app. +### Ruler guides + +`guide.add {vertical, offset, frame? | page?}` (returns `{index}`), `guide.move {index, offset, …}`, +`guide.remove {index, …}` and `guide.clear {…}` edit the guides of the current page, another +`page`, or a `frame` (frames, components and instances). A frame's guides are in its own +coordinates: they move with the frame, span only it, are copied with it and snap the layers inside +it. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's guide. + Tool failures come back as results with `isError: true` and a message; protocol problems (unknown tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.