Merge PR #559 from NikitkaRa: View: movable drawing guides saved with the presentation

# Conflicts:
#	crates/engine/src/tools.rs
#	crates/model/src/defaults.rs
#	crates/model/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:41:12 -04:00
10 changed files with 423 additions and 13 deletions
+85
View File
@@ -9,6 +9,18 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"guide.add",
"Add Guide",
["View", "Show", "Guides"],
None,
"{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none",
has_doc,
guide_add
),
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move),
cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete),
cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list),
cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)),
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
@@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})
}
/// Guides are kept on the slide; a file can hold at most this many.
const MAX_GUIDES: usize = 200;
fn guide_limit(s: &Session, vertical: bool) -> Result<f64> {
let size = s.doc()?.doc.slide_size;
Ok(if vertical { size.width } else { size.height })
}
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "defaults", false) {
if !s.doc()?.doc.guides.is_empty() {
return Ok(json!({"added": 0}));
}
let size = s.doc()?.doc.slide_size;
return s.edit(|doc, _| {
doc.guides = vec![
deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 },
deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 },
];
Ok(json!({"added": 2}))
});
}
let vertical = bool_or(p, "vertical", true);
let limit = guide_limit(s, vertical)?;
let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit);
if s.doc()?.doc.guides.len() >= MAX_GUIDES {
return Err(bad("guide.add", "too many guides"));
}
s.edit(|doc, _| {
doc.guides.push(deckcraft_model::DrawGuide { vertical, pos });
Ok(json!({"index": doc.guides.len() - 1}))
})
}
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?;
if i >= s.doc()?.doc.guides.len() {
return Err(bad(cmd, "no such guide"));
}
Ok(i)
}
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.move")?;
let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical);
let limit = guide_limit(s, vertical)?;
let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit);
s.edit(|doc, _| {
if let Some(g) = doc.guides.get_mut(i) {
g.pos = pos;
}
Ok(json!({"index": i, "pos": pos}))
})
}
fn guide_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.delete")?;
s.edit(|doc, _| {
if i < doc.guides.len() {
doc.guides.remove(i);
}
ok()
})
}
fn guide_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default())
}
#[cfg(test)]
#[path = "guides_tests.rs"]
mod guides_tests;
+76
View File
@@ -0,0 +1,76 @@
//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and
//! the PPTX round trip.
use serde_json::json;
use crate::Session;
use crate::tools::{Mods, PointerEvent, PointerKind};
fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0 }).unwrap();
}
#[test]
fn add_move_delete_and_undo() {
let mut s = Session::with_new();
assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2);
assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none");
let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap();
assert_eq!(i, 2);
s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide");
assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err());
assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err());
s.execute("guide.delete", &json!({"index": 0})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
}
#[test]
fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap();
// Hidden guides can't be grabbed: the press starts a marquee instead.
ev(&mut s, PointerKind::Down, 300.0, 50.0);
ev(&mut s, PointerKind::Up, 300.0, 50.0);
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0);
s.tool.guides_visible = true;
ev(&mut s, PointerKind::Down, 301.0, 50.0);
ev(&mut s, PointerKind::Drag, 350.4, 60.0);
assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging");
ev(&mut s, PointerKind::Up, 350.4, 60.0);
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0);
assert_eq!(s.tool.guide_drag, None);
// A shape dragged near the guide snaps its left edge onto it.
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap();
s.prefs.snap_to_grid = false;
ev(&mut s, PointerKind::Down, 140.0, 230.0);
ev(&mut s, PointerKind::Drag, 200.0, 230.0);
ev(&mut s, PointerKind::Drag, 388.0, 231.0);
ev(&mut s, PointerKind::Up, 388.0, 231.0);
let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap();
assert!((x.x - 350.0).abs() < 0.01, "{x:?}");
// Dropped off the slide: gone, and undo brings it back.
ev(&mut s, PointerKind::Down, 350.0, 10.0);
ev(&mut s, PointerKind::Drag, -40.0, 10.0);
ev(&mut s, PointerKind::Up, -40.0, 10.0);
assert!(s.doc().unwrap().doc.guides.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides.len(), 1);
}
#[test]
fn guides_round_trip_through_pptx() {
let mut s = Session::with_new();
s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap();
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
assert_eq!(back.guides, s.doc().unwrap().doc.guides);
let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap();
assert!(none.guides.is_empty());
}
+64
View File
@@ -150,6 +150,10 @@ enum Drag {
start: Point,
add: bool,
},
/// Dragging drawing guide `index`.
Guide {
index: usize,
},
Create {
start: Point,
},
@@ -211,6 +215,12 @@ pub struct ToolState {
pub edit_vertex: Option<(usize, usize)>,
/// The point type last chosen for a vertex (path, index, type): handle drags follow it.
pub vertex_kind: Option<(usize, usize, String)>,
/// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them.
pub guides_visible: bool,
/// The guide being dragged and where it is now (slide points); `None` once dropped.
pub guide_drag: Option<(usize, f64)>,
/// The drawing guide under the pointer, for the cursor.
pub guide_hover: Option<usize>,
}
impl ToolState {
@@ -341,6 +351,8 @@ impl Session {
return Ok(Value::Null);
}
self.tool.hover = self.hit_test(p, tol);
self.tool.guide_hover =
if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None };
let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset));
if line_tool {
self.update_sites(p, tol, None);
@@ -538,6 +550,14 @@ impl Session {
let _ = self.execute("animation.painterApply", &json!({"ids": [id]}));
}
}
None if self.guide_at(p, tol).is_some() => {
// Away from every shape a press on a guide picks the guide up.
if let Some(index) = self.guide_at(p, tol) {
let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0);
self.tool.drag = Some(Drag::Guide { index });
self.tool.guide_drag = Some((index, pos));
}
}
None => {
let add = mods.shift || mods.cmd;
self.select(|_, sel| {
@@ -557,6 +577,13 @@ impl Session {
fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag {
Drag::Guide { index } => {
let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical);
let pos = if vertical { p.x } else { p.y };
// Guides move in whole points unless snapping is off, as in PowerPoint.
let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos };
self.tool.guide_drag = Some((index, pos));
}
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
let mut d = p - start;
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
@@ -782,6 +809,18 @@ impl Session {
self.tool.glue = None;
let mut out = Value::Null;
match drag {
Some(Drag::Guide { index }) => {
// Dropped off the slide: the guide is deleted.
let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0));
let size = self.doc()?.doc.slide_size;
let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical);
let limit = if vertical { size.width } else { size.height };
out = if pos < 0.0 || pos > limit {
self.execute("guide.delete", &json!({"index": index}))?
} else {
self.execute("guide.move", &json!({"index": index, "pos": pos}))?
};
}
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
// Plain click on text: caret at the click.
let st = self.doc()?;
@@ -1114,6 +1153,22 @@ impl Session {
})
}
/// The drawing guide within `tol` of `p` (only while guides are shown).
pub fn guide_at(&self, p: Point, tol: f64) -> Option<usize> {
if !self.tool.guides_visible {
return None;
}
let st = self.active()?;
st.doc
.guides
.iter()
.enumerate()
.map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() }))
.filter(|(_, d)| *d <= tol.max(2.0))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to
/// apply and the guides to draw.
fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec<Guide>) {
@@ -1132,6 +1187,15 @@ impl Session {
(size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)),
(size.height, Rect::new(0.0, size.height, size.width, size.height)),
];
if self.tool.guides_visible {
for g in &st.doc.guides {
if g.vertical {
xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height)));
} else {
ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos)));
}
}
}
for sh in st.shapes() {
if exclude.contains(&sh.id) || sh.hidden {
continue;