mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user