From ef97225e07fe78619ea18dd5c2572677de972d32 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 03:07:57 +0700 Subject: [PATCH] M1: ruler guides: drag out of the rulers, move, drag back to remove, position tags (#9) - Page guides now draw on the canvas (under the rulers, shown with them). - Drag from the top ruler for a horizontal guide, from the left ruler for a vertical one; drag a guide (Move tool) to move it, back onto its ruler to remove it. Positions snap to whole pixels when pixel snapping is on; a tag shows the position while dragging or hovering. - Commands: `guide.add` returns the index, new `guide.move {index, offset}` and `guide.clear`; `guide.remove` and `guide.move` reject bad indexes (a negative index used to remove guide 0). Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/page.rs | 54 +++++++++-- crates/engine/src/tests.rs | 25 +++++ crates/ui-egui/src/canvas.rs | 5 + crates/ui-egui/src/guides.rs | 176 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + 6 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/guides.rs diff --git a/ROADMAP.md b/ROADMAP.md index 041cec433..23d92a2d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,7 +122,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Area | Weight | Score | Remaining work | Hours | |---|---|---|---|---| -| Canvas & navigation | 6 | 60% | rulers/guides dragging, pixel preview, minimap-free perf at scale | 8–12 | +| Canvas & navigation | 6 | 65% | pixel preview, frame guides, perf at scale | 6–10 | | Selection & transform | 8 | 60% | rotated multi-select handles, scale tool polish, measure hover polish | 10–15 | | Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 | | Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 | diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 52064a181..4b3e6585c 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -107,23 +107,57 @@ pub fn specs() -> Vec { .collect(), )) }), - spec!("guide.add", "Add guide", "{vertical (bool), offset, page?}", true, |s, p| { + spec!("guide.add", "Add guide", "{vertical (bool), offset, page?} → {index}", true, |s, p| { let id = id(p, "page").unwrap_or(s.page()?); - let g = Guide { vertical: boolean(p, "vertical").unwrap_or(true), offset: req_num("guide.add", p, "offset")? }; - if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind { - guides.push(g); + 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")), + } + }), + spec!("guide.move", "Move guide", "{index, offset, page?}", true, |s, p| { + let id = id(p, "page").unwrap_or(s.page()?); + 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")), } - ok() }), spec!("guide.remove", "Remove guide", "{index, page?}", true, |s, p| { let id = id(p, "page").unwrap_or(s.page()?); - let k = req_num("guide.remove", p, "index")? as usize; - if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind - && k < guides.len() - { - guides.remove(k); + 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")), + } + }), + 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(); } ok() }), ] } + +/// A guide index from params (never negative). +fn guide_index(cmd: &str, p: &Value) -> crate::Result { + let k = req_num(cmd, p, "index")?; + if !(0.0..1e9).contains(&k) { + return Err(bad(cmd, "`index` out of range")); + } + Ok(k as usize) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 59899e4ea..6be3892ea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -410,3 +410,28 @@ fn document_colors_are_counted_per_page() { let v = s.execute("paint.documentColors", &json!({})).unwrap(); assert!(v["colors"].as_array().unwrap().is_empty()); } + +#[test] +fn guides_add_move_remove() { + let mut s = sess(); + let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind { + NodeKind::Page { guides, .. } => guides.clone(), + _ => vec![], + }; + let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap(); + assert_eq!(r["index"], json!(0)); + s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap(); + s.execute("guide.move", &json!({"index": 0, "offset": 150})).unwrap(); + assert_eq!(guides(&s)[0].offset, 150.0); + s.undo().unwrap(); + assert_eq!(guides(&s)[0].offset, 120.0); + s.execute("guide.remove", &json!({"index": 1})).unwrap(); + assert_eq!(guides(&s).len(), 1); + for j in [json!({"index": -1}), json!({"index": 5, "offset": 1}), json!({"index": "x"}), json!({"offset": "NaN"})] { + assert!(s.execute("guide.remove", &j).is_err() || guides(&s).len() == 1, "{j}"); + let _ = s.execute("guide.move", &j); + } + assert_eq!(guides(&s).len(), 1, "junk never removes the first guide"); + s.execute("guide.clear", &json!({})).unwrap(); + assert!(guides(&s).is_empty()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 661dfdf35..5472b9f83 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -301,6 +301,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { if !app.canvas.dragging && resp.hovered() && crate::comments::input(app, &ctx, pos) { return; } + if !app.canvas.dragging && resp.hovered() && crate::guides::input(app, &ctx, pos) { + return; + } if !app.canvas.dragging && resp.hovered() && crate::handles::input(app, &ctx, pos) { return; } @@ -705,6 +708,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0)); } + // Guides (under the rulers). + crate::guides::draw(app, painter); // Rulers. if view.rulers { rulers(app, painter, t, &doc, &sel); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs new file mode 100644 index 000000000..ea82f3ce8 --- /dev/null +++ b/crates/ui-egui/src/guides.rs @@ -0,0 +1,176 @@ +//! 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. + +use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, pos2, vec2}; +use figcraft_doc::{Guide, NodeKind}; +use serde_json::json; + +use crate::FigApp; +use crate::theme::semibold; + +/// Ruler thickness (matches the canvas rulers). +const RULER: f32 = 20.0; +const GUIDE: Color32 = Color32::from_rgb(0xF2, 0x48, 0x22); + +#[derive(Clone, Copy, Debug)] +enum GuideDrag { + /// Dragging a new guide out of a ruler. + New { vertical: bool }, + /// Moving guide `index`. + Move { index: usize, vertical: bool }, +} + +fn drag_id() -> Id { + Id::new("guide_drag") +} + +/// The dragged guide's live position (page units). +fn preview_id() -> Id { + Id::new("guide_preview") +} + +fn page_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![], + } +} + +fn rulers_on(app: &FigApp) -> bool { + app.session.ds().is_ok_and(|d| d.view.rulers) +} + +/// 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 } +} + +/// 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)) +} + +fn cursor(vertical: bool) -> egui::CursorIcon { + if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } +} + +/// Pointer handling; returns true when the event belongs to a ruler or guide. +pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { + if !rulers_on(app) { + return false; + } + let r = app.canvas.rect; + let (pressed, released) = + ctx.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary))); + if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { + let vertical = match drag { + GuideDrag::New { vertical } | GuideDrag::Move { vertical, .. } => vertical, + }; + 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))); + ctx.set_cursor_icon(cursor(vertical)); + if released { + ctx.data_mut(|d| { + d.remove::(drag_id()); + d.remove::<(bool, f64)>(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})); + } + (GuideDrag::Move { index, .. }, true) => { + app.run("guide.remove", json!({"index": index})); + } + (GuideDrag::Move { index, .. }, false) => { + app.run("guide.move", json!({"index": index, "offset": offset})); + } + (GuideDrag::New { .. }, true) => {} + } + } + ctx.request_repaint(); + return true; + } + let in_top = pos.y < r.min.y + RULER && pos.x > r.min.x + RULER; + let in_left = pos.x < r.min.x + RULER && pos.y > r.min.y + RULER; + if in_top || in_left { + ctx.set_cursor_icon(cursor(in_left)); + if pressed { + ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::New { vertical: in_left })); + } + return true; + } + if pos.x < r.min.x + RULER || pos.y < r.min.y + RULER { + return true; + } + // 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 pressed { + ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::Move { index, vertical })); + } + return true; + } + false +} + +/// Draw the page's 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; + } + let r = app.canvas.rect; + let ctx = painter.ctx(); + let drag = ctx.data(|d| d.get_temp::(drag_id())); + let moving = match drag { + Some(GuideDrag::Move { index, .. }) => Some(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 { + 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); + } + } + 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); + } +} + +/// 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); + 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 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/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e96836ae6..dfb99caf2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod component_panel; pub mod control; pub mod credits; pub mod dialogs; +pub mod guides; pub mod handles; pub mod home; pub mod icons;