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 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 03:07:57 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b7910c81f8
commit ef97225e07
6 changed files with 252 additions and 11 deletions
+1 -1
View File
@@ -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 |
+44 -10
View File
@@ -107,23 +107,57 @@ pub fn specs() -> Vec<CommandSpec> {
.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<usize> {
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)
}
+25
View File
@@ -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());
}
+5
View File
@@ -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);
+176
View File
@@ -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<Guide> {
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::<GuideDrag>(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::<GuideDrag>(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::<GuideDrag>(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);
}
+1
View File
@@ -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;