mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -515,6 +515,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
|
||||
table_styles: vec![],
|
||||
tags: vec![],
|
||||
kept_parts: vec![],
|
||||
guides: vec![],
|
||||
raw_ext: None,
|
||||
};
|
||||
if title_slide {
|
||||
|
||||
@@ -974,11 +974,23 @@ pub struct Presentation {
|
||||
/// sensitivity label and custom document properties.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub kept_parts: Vec<KeptPart>,
|
||||
/// 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)
|
||||
|
||||
@@ -578,6 +578,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)
|
||||
|
||||
@@ -687,7 +687,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> 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(), handout_rid.as_deref(), &slide_rids);
|
||||
@@ -1155,13 +1155,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()
|
||||
|
||||
@@ -200,16 +200,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
{
|
||||
draw_grid(&painter, slide_rect, 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);
|
||||
@@ -602,6 +595,54 @@ fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if crate::eyedropper::canvas(app, ui, resp, xf) {
|
||||
return;
|
||||
@@ -661,7 +702,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);
|
||||
@@ -1040,6 +1085,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();
|
||||
@@ -1201,3 +1259,7 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "ruler_tests.rs"]
|
||||
mod ruler_tests;
|
||||
|
||||
#[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);
|
||||
}
|
||||
@@ -550,7 +550,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.gridOptions" => {
|
||||
if grid_options_touched(p) {
|
||||
let spacing = match p.get("spacing") {
|
||||
|
||||
Reference in New Issue
Block a user