View: movable drawing guides saved with the presentation

View > Guides used to paint two fixed centre lines. Guides now belong
to the presentation (DrawGuide, read from and written to p:guideLst in
viewProps.xml, in eighths of a point), so they survive a save and
reopen in PowerPoint.

- Turning Guides on gives a deck without guides PowerPoint's two
  centre guides.
- A guide can be dragged anywhere away from shapes. It moves in whole
  points, unless Alt is held or snapping is off, and shows its
  distance from the centre while it moves.
- A guide dropped off the slide is deleted.
- The slide context menu's Grid and Guides submenu has Add Vertical
  Guide and Add Horizontal Guide.
- While guides are shown, moving and resizing shapes snaps to them.

New commands guide.add, guide.move and guide.delete are undoable;
guide.list is a query.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:55:59 +03:00
co-authored by Cursor
parent 457406a7a5
commit b763579d94
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
@@ -123,6 +123,10 @@ enum Drag {
start: Point,
add: bool,
},
/// Dragging drawing guide `index`.
Guide {
index: usize,
},
Create {
start: Point,
},
@@ -178,6 +182,12 @@ pub struct ToolState {
pub path_preview: Vec<Point>,
pub path_smooth: bool,
pub path_closing: bool,
/// 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 {
@@ -286,6 +296,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);
@@ -451,6 +463,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| {
@@ -470,6 +490,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()) {
@@ -668,6 +695,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()?;
@@ -942,6 +981,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>) {
@@ -960,6 +1015,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;
+1
View File
@@ -513,6 +513,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
next_id: next.max(256),
embedded_fonts: vec![],
table_styles: vec![],
guides: vec![],
raw_ext: None,
};
if title_slide {
+12
View File
@@ -843,11 +843,23 @@ pub struct Presentation {
/// Table styles defined in the file (`ppt/tableStyles.xml`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub table_styles: Vec<table::TableStyleDef>,
/// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<DrawGuide>,
/// Unmodelled presentation-level XML kept for round-trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
}
/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal
/// one from its top.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DrawGuide {
pub vertical: bool,
pub pos: f64,
}
impl Default for Presentation {
fn default() -> Self {
defaults::new_presentation(None)
+14
View File
@@ -357,6 +357,20 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides });
}
}
// Drawing guides (eighths of a point).
if let Some(r) = pres_part.rels.first_of("viewProps")
&& let Some(d) = pkg.xml(&r.target)
&& let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst"))
{
p.guides = lst
.children_named("guide")
.filter_map(|g| {
let pos = g.attr("pos").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) / 8.0;
pos.is_finite().then_some(deckcraft_model::DrawGuide { vertical: g.attr("orient") != Some("horz"), pos })
})
.take(1000)
.collect();
}
// Show settings.
if let Some(r) = pres_part.rels.first_of("presProps")
&& let Some(d) = pkg.xml(&r.target)
+23 -2
View File
@@ -511,7 +511,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
pres.rels.add("viewProps", "viewProps.xml", false);
pres.rels.add("tableStyles", "tableStyles.xml", false);
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml(p));
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
@@ -767,13 +767,34 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
w.finish()
}
fn view_props_xml() -> Vec<u8> {
fn view_props_xml(p: &Presentation) -> Vec<u8> {
let mut w = W::new();
root_open(&mut w, "p:viewPr", A::new());
w.open0("p:normalViewPr");
w.empty("p:restoredLeft", A::new().a("sz", 15620));
w.empty("p:restoredTop", A::new().a("sz", 94660));
w.close("p:normalViewPr");
if !p.guides.is_empty() {
// Drawing guides; `pos` is in eighths of a point (576 per inch).
w.open0("p:slideViewPr");
w.open0("p:cSldViewPr");
w.open0("p:cViewPr");
w.open0("p:scale");
w.empty("a:sx", A::new().a("n", 100).a("d", 100));
w.empty("a:sy", A::new().a("n", 100).a("d", 100));
w.close("p:scale");
w.empty("p:origin", A::new().a("x", 0).a("y", 0));
w.close("p:cViewPr");
w.open0("p:guideLst");
for g in p.guides.iter().take(1000) {
let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64;
let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) };
w.empty("p:guide", a);
}
w.close("p:guideLst");
w.close("p:cSldViewPr");
w.close("p:slideViewPr");
}
w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200));
w.close("p:viewPr");
w.finish()
+72 -10
View File
@@ -148,16 +148,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
y += step;
}
}
app.session.tool.guides_visible = app.ui.guides;
if app.ui.guides {
let c = slide_rect.center();
painter.line_segment(
[pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)],
Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)),
);
painter.line_segment(
[pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)],
Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)),
);
draw_guides(app, &painter, xf, slide_rect, &t);
}
if app.ui.ruler {
ruler(&painter, avail, slide_rect, scale, &t);
@@ -366,6 +359,54 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
}
}
/// The presentation's drawing guides; the one being dragged at its new place, labelled with its
/// distance from the slide's centre in points.
fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) {
let Some(st) = app.session.active() else { return };
let size = st.doc.slide_size;
let drag = app.session.tool.guide_drag;
for (i, g) in st.doc.guides.iter().enumerate() {
let (pos, active) = match drag {
Some((di, p)) if di == i => (p, true),
_ => (g.pos, app.session.tool.guide_hover == Some(i)),
};
let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) };
let stroke = Stroke::new(1.0, col);
let (a, b) = if g.vertical {
let x = xf.to_screen(Point::new(pos, 0.0)).x;
(pos2(x, slide.min.y), pos2(x, slide.max.y))
} else {
let y = xf.to_screen(Point::new(0.0, pos)).y;
(pos2(slide.min.x, y), pos2(slide.max.x, y))
};
// Dashed, like PowerPoint's guides.
let len = (b - a).length();
let dir = (b - a) / len.max(1.0);
let mut d = 0.0;
while d < len {
painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke);
d += 7.0;
}
if drag.is_some_and(|(di, _)| di == i) {
let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 };
let off = pos - centre;
let side = match (g.vertical, off < 0.0, off > 0.0) {
(true, true, _) => " left of centre",
(true, _, true) => " right of centre",
(false, true, _) => " above centre",
(false, _, true) => " below centre",
_ => " (centre)",
};
let label = format!("{:.0} pt{side}", off.abs());
let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) };
let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text);
let r = Rect::from_min_size(at, galley.size()).expand(3.0);
painter.rect_filled(r, CornerRadius::same(3), t.card);
painter.galley(at, galley, t.text);
}
}
}
fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
let tol = (4.0 / xf.scale) as f64;
let m = mods(ui);
@@ -413,7 +454,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
}
}
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
_ => egui::CursorIcon::Default,
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
Some(_) => egui::CursorIcon::ResizeVertical,
None => egui::CursorIcon::Default,
},
};
if resp.hovered() {
ui.ctx().set_cursor_icon(cursor);
@@ -682,6 +727,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
}
}
});
ui.menu_button("Grid and Guides", |ui| {
let mut on = app.ui.guides;
if ui.checkbox(&mut on, "Guides").clicked() {
let _ = app.run("view.guides", json!({"on": on}));
}
for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] {
if ui.button(label).clicked() {
let _ = app.run("guide.add", json!({"vertical": vertical}));
let _ = app.run("view.guides", json!({"on": true}));
ui.close();
}
}
});
if ui.button("Reset Slide").clicked() {
let _ = app.run("slide.reset", json!({}));
ui.close();
@@ -697,3 +755,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
}
});
}
#[cfg(test)]
#[path = "guides_tests.rs"]
mod guides_tests;
+69
View File
@@ -0,0 +1,69 @@
//! Drawing guides driven with the mouse: View › Guides adds the centre guides, dragging one
//! moves it (with its distance from the centre shown), dropping it off the slide deletes it.
use deckcraft_engine::Session;
use egui::{Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
use crate::{Services, SlideApp};
#[test]
fn turn_guides_on_drag_one_and_drop_it_off_the_slide() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let app = SlideApp::new(s, Services::default());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
h.state_mut().run("view.guides", json!({"on": true})).unwrap();
h.run_steps(2);
let guides = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.guides.clone();
assert_eq!(guides(&h).len(), 2, "the two centre guides");
let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k);
// Grab the vertical guide away from the horizontal one and drag it left.
let start = at(480.0, 100.0);
h.hover_at(start);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=10 {
h.event(egui::Event::PointerMoved(at(480.0 - 18.0 * i as f32, 100.0)));
h.run_steps(1);
}
assert_eq!(h.state().session.tool.guide_drag, Some((0, 300.0)));
if let Ok(img) = h.render() {
let _ = img.save("/tmp/guides.png");
}
h.event(egui::Event::PointerButton { pos: at(300.0, 100.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(guides(&h)[0].pos, 300.0);
// Off the top of the slide with the horizontal guide: deleted.
let g = at(700.0, 270.0);
h.hover_at(g);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: g, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=10 {
h.event(egui::Event::PointerMoved(at(700.0, 270.0 - 30.0 * i as f32)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: at(700.0, -30.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(guides(&h).len(), 1);
assert!(guides(&h)[0].vertical);
// Hidden guides stay in the file but aren't drawn or grabbed.
h.state_mut().run("view.guides", json!({"on": false})).unwrap();
h.run_steps(2);
assert!(!h.state().session.tool.guides_visible);
assert_eq!(guides(&h).len(), 1);
}
+7 -1
View File
@@ -457,7 +457,13 @@ impl SlideApp {
"view.notes" => self.ui.notes = self.flag(p, self.ui.notes),
"view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler),
"view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines),
"view.guides" => self.ui.guides = self.flag(p, self.ui.guides),
"view.guides" => {
self.ui.guides = self.flag(p, self.ui.guides);
// A presentation without guides gets PowerPoint's two centre guides.
if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) {
let _ = self.session.execute("guide.add", &json!({"defaults": true}));
}
}
"view.zoomIn" | "view.zoomOut" => {
let cur = self.ui.zoom.unwrap_or(self.canvas_scale);
let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];