From 6f9801790c05a6e13459a11a75d38ad307917ac6 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 12:16:56 +0700 Subject: [PATCH] M1: frame guides (#70) Ruler guides can belong to a frame: `guide.add/move/remove/clear` take `frame` (as well as `page`), keeping the guide in the frame's own coordinates. Frame guides move with the frame, span only it on the canvas, are saved and copied with it, and snap the layers inside it. A guide dragged out of a ruler and dropped on a frame becomes that frame's guide; its label shows the offset in the frame. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/node.rs | 19 +++ crates/engine/src/cmd/page.rs | 68 ++++---- crates/engine/src/frame_guides_tests.rs | 97 +++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/snap.rs | 17 ++ crates/ui-egui/src/guides.rs | 218 +++++++++++++++++------- docs/mcp.md | 8 + 8 files changed, 338 insertions(+), 93 deletions(-) create mode 100644 crates/engine/src/frame_guides_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 684b5572c..ce6e6d29a 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 56e0b05fc..982e55b9f 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 12e6433ae..74acac7ac 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 000000000..7f12161c3 --- /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 bdb4a0242..5dc638f46 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 71cb245fe..353081b1b 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 ea82f3ce8..1f2d19790 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 5a9abdccf..3bf5a1d7e 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.