Compare commits
1 Commits
main
...
fix/off-sl
| Author | SHA1 | Date |
|---|---|---|
|
|
2e7babc53e |
|
|
@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||||
|
|
||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||||
|
|
|
||||||
|
|
@ -567,3 +567,42 @@ fn inserted_hyperlinks_look_like_links() {
|
||||||
s.execute("format.underline", &json!({})).unwrap();
|
s.execute("format.underline", &json!({})).unwrap();
|
||||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed
|
||||||
|
/// like any other; one can be dragged back onto the slide or out of it.
|
||||||
|
#[test]
|
||||||
|
fn shapes_off_the_slide_can_be_selected_and_moved() {
|
||||||
|
let mut s = session();
|
||||||
|
s.prefs.snap_to_grid = false;
|
||||||
|
s.prefs.smart_guides = false;
|
||||||
|
let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
// Inserting off the slide keeps the box where it was asked for.
|
||||||
|
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
|
||||||
|
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||||
|
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||||
|
// Hover and click wholly off the slide.
|
||||||
|
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
|
||||||
|
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![off]);
|
||||||
|
// Drag it further off, then back onto the slide.
|
||||||
|
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap();
|
||||||
|
let v = s.execute("shape.inspect", &json!({"id": off})).unwrap();
|
||||||
|
assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0)));
|
||||||
|
s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap();
|
||||||
|
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0);
|
||||||
|
// The off-slide half of a straddling shape is hit too.
|
||||||
|
s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||||
|
// A marquee started on the pasteboard selects what it encloses there.
|
||||||
|
s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
//! The pasteboard: what the editor draws around the slide.
|
||||||
|
//!
|
||||||
|
//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
|
||||||
|
//! on the area around it, at full strength and with their effects (master and layout graphics
|
||||||
|
//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
|
||||||
|
//!
|
||||||
|
//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
|
||||||
|
//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
|
||||||
|
|
||||||
|
use deckcraft_geom::Xfrm;
|
||||||
|
use deckcraft_model::resolve::{self, Ctx};
|
||||||
|
use deckcraft_model::style::{Effects, Line};
|
||||||
|
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||||
|
use kurbo::Rect;
|
||||||
|
|
||||||
|
use crate::MAX_SIDE;
|
||||||
|
|
||||||
|
/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
|
||||||
|
/// hostile files can't blow the pasteboard up.
|
||||||
|
const MAX_SPILL: f64 = 2000.0;
|
||||||
|
|
||||||
|
/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
|
||||||
|
/// that is wider than the window re-renders only when a new tile comes into view.
|
||||||
|
pub const TILE: i64 = 256;
|
||||||
|
|
||||||
|
/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
|
||||||
|
/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
|
||||||
|
pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
|
||||||
|
let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
|
||||||
|
let mut s: f64 = 0.0;
|
||||||
|
if let Some(l) = line
|
||||||
|
&& l.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||||
|
{
|
||||||
|
// Arrowheads reach up to a few line widths past the end point.
|
||||||
|
s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
|
||||||
|
}
|
||||||
|
if let Some(e) = effects {
|
||||||
|
let big = fin(w).max(fin(h));
|
||||||
|
if let Some(sh) = &e.outer_shadow {
|
||||||
|
let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
|
||||||
|
s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
|
||||||
|
}
|
||||||
|
if let Some(g) = &e.glow {
|
||||||
|
s = s.max(fin(g.radius));
|
||||||
|
}
|
||||||
|
if let Some(r) = &e.reflection {
|
||||||
|
s = s.max(fin(h) + fin(r.dist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.min(MAX_SPILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slide-space box a top-level shape draws into, its spill included.
|
||||||
|
fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
|
||||||
|
if s.hidden {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let x: Xfrm = resolve::xfrm(rctx, s);
|
||||||
|
if !x.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let b = match &s.kind {
|
||||||
|
ShapeKind::Ink { strokes } => {
|
||||||
|
let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite());
|
||||||
|
let (px, py, _) = pts.next()?;
|
||||||
|
let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py)));
|
||||||
|
let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL);
|
||||||
|
return Some(r.inflate(w, w));
|
||||||
|
}
|
||||||
|
_ => x.bounds(),
|
||||||
|
};
|
||||||
|
let (line, _) = resolve::line(rctx, s);
|
||||||
|
let (fx, _) = resolve::effects(rctx, s);
|
||||||
|
let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
|
||||||
|
Some(b.inflate(d, d))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
|
||||||
|
shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slide_rect(pres: &Presentation) -> Rect {
|
||||||
|
Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
|
||||||
|
/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
|
||||||
|
/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
|
||||||
|
pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
|
||||||
|
let page = slide_rect(pres);
|
||||||
|
let Some(slide) = pres.slides.get(index) else { return page };
|
||||||
|
let Some(rctx) = Ctx::for_slide(pres, slide) else { return page };
|
||||||
|
let mut r = union_boxes(&rctx, &slide.shapes, page);
|
||||||
|
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||||
|
if show_master {
|
||||||
|
let mctx = Ctx::for_master(pres, rctx.master);
|
||||||
|
r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||||
|
}
|
||||||
|
if show_layout && let Some(l) = rctx.layout {
|
||||||
|
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||||
|
r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
|
||||||
|
pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
|
||||||
|
let page = slide_rect(pres);
|
||||||
|
let Some(m) = pres.masters.get(master) else { return page };
|
||||||
|
match layout.and_then(|i| m.layouts.get(i)) {
|
||||||
|
Some(l) => {
|
||||||
|
let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
|
||||||
|
if l.show_master_shapes {
|
||||||
|
r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r);
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Region {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub w: u32,
|
||||||
|
pub h: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Region {
|
||||||
|
/// The slide itself, `w` × `h` pixels.
|
||||||
|
pub fn slide(w: u32, h: u32) -> Self {
|
||||||
|
Region { x: 0, y: 0, w: w.max(1), h: h.max(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edges(&self) -> (i64, i64, i64, i64) {
|
||||||
|
let (x, y) = (self.x as i64, self.y as i64);
|
||||||
|
(x, y, x + self.w as i64, y + self.h as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
|
||||||
|
let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
|
||||||
|
let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
|
||||||
|
Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this region cover all of `other`?
|
||||||
|
pub fn contains(&self, other: &Region) -> bool {
|
||||||
|
let (a, b) = (self.edges(), other.edges());
|
||||||
|
a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the region more than the slide (some of the pasteboard is drawn)?
|
||||||
|
pub fn beyond(&self, slide: (u32, u32)) -> bool {
|
||||||
|
*self != Region::slide(slide.0, slide.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
|
||||||
|
pub fn offset(&self) -> (f64, f64) {
|
||||||
|
(-(self.x as f64), -(self.y as f64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The region's size, for render options' `size`.
|
||||||
|
pub fn size(&self) -> (u32, u32) {
|
||||||
|
(self.w, self.h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
|
||||||
|
fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
|
||||||
|
let lim = i32::MAX as f64 / 2.0;
|
||||||
|
let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None };
|
||||||
|
Some((
|
||||||
|
px((r.x0 * scale).floor())? as i64,
|
||||||
|
px((r.y0 * scale).floor())? as i64,
|
||||||
|
px((r.x1 * scale).ceil())? as i64,
|
||||||
|
px((r.y1 * scale).ceil())? as i64,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
|
||||||
|
/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
|
||||||
|
/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
|
||||||
|
/// area is rounded out to whole [`TILE`]s.
|
||||||
|
pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region {
|
||||||
|
let page = Region::slide(slide_px.0, slide_px.1);
|
||||||
|
if !(scale.is_finite() && scale > 0.0) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page };
|
||||||
|
let (vx0, vy0, vx1, vy1) = visible.edges();
|
||||||
|
let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
|
||||||
|
let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE);
|
||||||
|
let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
|
||||||
|
if cx0 >= cx1 || cy0 >= cy1 {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
let (px0, py0, px1, py1) = page.edges();
|
||||||
|
Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use deckcraft_model::Shape;
|
||||||
|
|
||||||
|
fn black() -> deckcraft_model::ColorRef {
|
||||||
|
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() });
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
|
||||||
|
let p = deck_with(100.0, 100.0, 50.0, 50.0);
|
||||||
|
assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shapes_off_the_slide_grow_the_extent() {
|
||||||
|
let p = deck_with(-200.0, 50.0, 100.0, 80.0);
|
||||||
|
let e = slide_extent(&p, 0);
|
||||||
|
assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
|
||||||
|
assert_eq!(e.y0, 0.0);
|
||||||
|
assert_eq!(e.x1, p.slide_size.width);
|
||||||
|
// Missing slides are just the page.
|
||||||
|
assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
|
||||||
|
let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
|
||||||
|
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true;
|
||||||
|
let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
|
||||||
|
assert_eq!(slide_extent(&p, 0), page);
|
||||||
|
let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
|
||||||
|
assert_eq!(slide_extent(&p, 0), page);
|
||||||
|
let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
|
||||||
|
assert_eq!(slide_extent(&p, 0), page);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effects_count_towards_the_extent() {
|
||||||
|
let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
|
||||||
|
let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap();
|
||||||
|
sh.effects = Some(Effects {
|
||||||
|
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||||
|
color: black(),
|
||||||
|
blur: 10.0,
|
||||||
|
dist: 20.0,
|
||||||
|
dir: 270.0,
|
||||||
|
inner: false,
|
||||||
|
sx: 1.0,
|
||||||
|
sy: 1.0,
|
||||||
|
kx: 0.0,
|
||||||
|
ky: 0.0,
|
||||||
|
align: String::new(),
|
||||||
|
rotate_with_shape: false,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(slide_extent(&p, 0).y0 <= -130.0);
|
||||||
|
assert_eq!(
|
||||||
|
spill(
|
||||||
|
None,
|
||||||
|
Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }),
|
||||||
|
1.0,
|
||||||
|
1.0
|
||||||
|
),
|
||||||
|
MAX_SPILL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||||
|
m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() });
|
||||||
|
assert!(slide_extent(&p, 0).x0 <= -150.0);
|
||||||
|
assert!(layout_extent(&p, 0, None).x0 <= -150.0);
|
||||||
|
assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
|
||||||
|
let slide = (960, 540);
|
||||||
|
let page = Region::slide(960, 540);
|
||||||
|
let view = Region { x: -100, y: -50, w: 1160, h: 640 };
|
||||||
|
let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
|
||||||
|
assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
|
||||||
|
assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
|
||||||
|
// Off to the left, in view: the region reaches it.
|
||||||
|
let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||||
|
assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
|
||||||
|
assert!(r.beyond(slide) && r.contains(&page));
|
||||||
|
assert_eq!(r.offset(), (80.0, 0.0));
|
||||||
|
// Far off and out of view (the view rounds out to tiles): only what's visible.
|
||||||
|
let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
|
||||||
|
assert_eq!(r.x, -256);
|
||||||
|
// Wholly out of view: the slide.
|
||||||
|
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page);
|
||||||
|
// Scale applies; nonsense falls back to the slide.
|
||||||
|
let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
|
||||||
|
assert_eq!(r.x, -40);
|
||||||
|
assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
|
||||||
|
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page);
|
||||||
|
// Huge extents are capped.
|
||||||
|
let r =
|
||||||
|
pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 });
|
||||||
|
assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_within_a_tile_keeps_the_region() {
|
||||||
|
let slide = (2000, 1125);
|
||||||
|
let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
|
||||||
|
let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 });
|
||||||
|
let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 });
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ mod chart;
|
||||||
mod colr;
|
mod colr;
|
||||||
|
|
||||||
mod compound;
|
mod compound;
|
||||||
|
mod extent;
|
||||||
mod images;
|
mod images;
|
||||||
mod metafile;
|
mod metafile;
|
||||||
mod morph_path;
|
mod morph_path;
|
||||||
|
|
@ -40,6 +41,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
|
||||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||||
use vello_cpu::{RenderContext, Resources, peniko};
|
use vello_cpu::{RenderContext, Resources, peniko};
|
||||||
|
|
||||||
|
pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
|
||||||
pub use images::{decode as decode_image, gif_frame};
|
pub use images::{decode as decode_image, gif_frame};
|
||||||
pub use morph_path::PathMorph;
|
pub use morph_path::PathMorph;
|
||||||
pub use morph_text::TextMorph;
|
pub use morph_text::TextMorph;
|
||||||
|
|
@ -233,11 +235,14 @@ pub struct Renderer {
|
||||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||||
|
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||||||
|
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||||||
|
drawn: Option<Rect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Renderer {
|
impl Default for Renderer {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,14 +290,38 @@ fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||||
/// blank image (logged), never a crash.
|
/// blank image (logged), never a crash.
|
||||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||||
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
render_slide_drawn(pres, index, opts).0
|
||||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
|
}
|
||||||
Ok(img) => img,
|
|
||||||
|
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
|
||||||
|
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
|
||||||
|
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
|
||||||
|
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
|
||||||
|
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
|
||||||
|
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
|
||||||
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
RENDERER.with(|r| {
|
||||||
|
let mut r = r.borrow_mut();
|
||||||
|
r.drawn = None;
|
||||||
|
let img = r.slide(pres, slide, index, opts);
|
||||||
|
(img, r.drawn.take())
|
||||||
|
})
|
||||||
|
})) {
|
||||||
|
Ok((img, drawn)) => {
|
||||||
|
let k = opts.scale;
|
||||||
|
let to_slide =
|
||||||
|
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
|
||||||
|
let extent = match drawn {
|
||||||
|
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
|
||||||
|
_ => page,
|
||||||
|
};
|
||||||
|
(img, extent)
|
||||||
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
log::error!("rendering slide {index} panicked; showing a blank slide");
|
log::error!("rendering slide {index} panicked; showing a blank slide");
|
||||||
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
||||||
let (w, h) = output_size(pres, opts);
|
let (w, h) = output_size(pres, opts);
|
||||||
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
|
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,6 +501,14 @@ impl Renderer {
|
||||||
(ctx, w, h, view)
|
(ctx, w, h, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Note that `r` (local space, placed by `m`) was drawn.
|
||||||
|
fn track(&mut self, m: Affine, r: Rect) {
|
||||||
|
let b = m.transform_rect_bbox(r);
|
||||||
|
if b.is_finite() {
|
||||||
|
self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||||
}
|
}
|
||||||
|
|
@ -658,10 +695,21 @@ impl Renderer {
|
||||||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields),
|
ShapeKind::Table(t) => {
|
||||||
ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h),
|
// Rows grow with their text: what the cells cover, borders included.
|
||||||
|
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
|
||||||
|
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||||||
|
}
|
||||||
|
ShapeKind::Chart(c) => {
|
||||||
|
self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
|
||||||
|
chart::draw(ctx, rctx, c, m, x.w, x.h)
|
||||||
|
}
|
||||||
ShapeKind::Ink { strokes } => {
|
ShapeKind::Ink { strokes } => {
|
||||||
for sk in strokes {
|
for sk in strokes {
|
||||||
|
let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
|
||||||
|
for (px, py, _) in &sk.points {
|
||||||
|
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
|
||||||
|
}
|
||||||
let mut bp = BezPath::new();
|
let mut bp = BezPath::new();
|
||||||
for (i, (px, py, _)) in sk.points.iter().enumerate() {
|
for (i, (px, py, _)) in sk.points.iter().enumerate() {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
|
@ -692,6 +740,8 @@ impl Renderer {
|
||||||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||||||
let (line, line_ph) = resolve::line(rctx, s);
|
let (line, line_ph) = resolve::line(rctx, s);
|
||||||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||||||
|
let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
|
||||||
|
self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
|
||||||
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
|
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
|
||||||
let outline = geo.outline();
|
let outline = geo.outline();
|
||||||
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
|
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
|
||||||
|
|
@ -825,6 +875,12 @@ impl Renderer {
|
||||||
let tr = geo.text_rect;
|
let tr = geo.text_rect;
|
||||||
if !body.is_empty() {
|
if !body.is_empty() {
|
||||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||||
|
let tm = text_transform(m, tr, l.rotation);
|
||||||
|
for b in l.lines.iter().filter_map(line_bounds) {
|
||||||
|
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||||
|
let pad = b.height() * 0.3;
|
||||||
|
self.track(tm, b.inflate(pad, pad));
|
||||||
|
}
|
||||||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||||||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||||||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||||||
|
|
|
||||||
|
|
@ -297,9 +297,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
/// Draws the table; returns the box its cells cover (table space), which grows with their text.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn draw(
|
||||||
|
ctx: &mut RenderContext,
|
||||||
|
pres: &Presentation,
|
||||||
|
rctx: &Ctx,
|
||||||
|
t: &Table,
|
||||||
|
m: Affine,
|
||||||
|
_w: f64,
|
||||||
|
_h: f64,
|
||||||
|
fields: &dyn deckcraft_text::Fields,
|
||||||
|
) -> Option<kurbo::Rect> {
|
||||||
let _ = pres;
|
let _ = pres;
|
||||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||||
|
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
|
||||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||||
let style_bg = rctx
|
let style_bg = rctx
|
||||||
.pres
|
.pres
|
||||||
|
|
@ -379,4 +391,5 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||||
let _ = peniko::Fill::NonZero;
|
let _ = peniko::Fill::NonZero;
|
||||||
}
|
}
|
||||||
|
covered
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1177,3 +1177,94 @@ fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
|
||||||
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
|
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
|
||||||
|
fn off_slide_deck() -> Presentation {
|
||||||
|
let half = Shape {
|
||||||
|
id: ShapeId(2700),
|
||||||
|
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
|
||||||
|
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let below = Shape {
|
||||||
|
id: ShapeId(2701),
|
||||||
|
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
|
||||||
|
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
deck_with(vec![half, below])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
|
||||||
|
(px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_exports_and_thumbnails_clip_to_the_slide() {
|
||||||
|
let p = off_slide_deck();
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
assert_eq!((img.width, img.height), (960, 540));
|
||||||
|
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||||
|
// The on-slide half is there, nothing else is drawn outside the slide.
|
||||||
|
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
|
||||||
|
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
|
||||||
|
let p = off_slide_deck();
|
||||||
|
let extent = slide_extent(&p, 0);
|
||||||
|
let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 });
|
||||||
|
assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}");
|
||||||
|
let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||||
|
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
|
||||||
|
assert_eq!((img.width, img.height), region.size());
|
||||||
|
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
|
||||||
|
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||||
|
let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2);
|
||||||
|
// Both halves of the straddling shape, at full strength, and the shape below the slide.
|
||||||
|
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
|
||||||
|
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
|
||||||
|
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
|
||||||
|
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
|
||||||
|
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
|
||||||
|
assert_eq!(at(200, 300), [255, 255, 255, 255]);
|
||||||
|
// The drawing's reach, in slide space.
|
||||||
|
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||||
|
m.shapes.push(Shape {
|
||||||
|
id: ShapeId(2702),
|
||||||
|
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
|
||||||
|
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 });
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() });
|
||||||
|
let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3);
|
||||||
|
assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||||
|
let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() };
|
||||||
|
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
|
||||||
|
for para in &mut t.paragraphs {
|
||||||
|
for r in &mut para.runs {
|
||||||
|
r.props.size = Some(40.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sh.text = Some(t);
|
||||||
|
let p = deck_with(vec![sh]);
|
||||||
|
// The box estimate doesn't see the text below the slide; the render does.
|
||||||
|
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
|
||||||
|
let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||||
|
assert!(drawn.y1 > 600.0, "{drawn:?}");
|
||||||
|
// A slide with nothing off it reaches just the slide.
|
||||||
|
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||||||
|
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
|
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
|
||||||
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
|
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
|
||||||
//! previews, the text caret and text selection).
|
//! previews, the text caret and text selection).
|
||||||
|
//!
|
||||||
|
//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the
|
||||||
|
//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved;
|
||||||
|
//! the slide show, exports and thumbnails clip to the slide.
|
||||||
|
|
||||||
use deckcraft_engine::tools::{Hit, box_handles};
|
use deckcraft_engine::tools::{Hit, box_handles};
|
||||||
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
|
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
|
||||||
|
|
@ -108,18 +112,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||||
let st = app.session.active();
|
let st = app.session.active();
|
||||||
|
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||||
|
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||||
|
let visible = visible_region(avail, slide_rect, pixel);
|
||||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||||
Some((Target::Slides, d)) => {
|
Some((Target::Slides, d)) => {
|
||||||
let idx = d.selection.slide;
|
let idx = d.selection.slide;
|
||||||
let doc = d.doc.clone();
|
let doc = d.doc.clone();
|
||||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||||
}
|
}
|
||||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
|
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
|
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
if let Some(tex) = tex {
|
if let Some((tex, region)) = tex {
|
||||||
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
let at = Rect::from_min_size(
|
||||||
|
slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y),
|
||||||
|
vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y),
|
||||||
|
);
|
||||||
|
app.canvas_region = Some(at);
|
||||||
|
painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||||
}
|
}
|
||||||
app.perf.render_ms = app.textures.last_render_ms;
|
app.perf.render_ms = app.textures.last_render_ms;
|
||||||
media_frames(app, &painter, xf);
|
media_frames(app, &painter, xf);
|
||||||
|
|
@ -157,6 +169,22 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||||
context_menu(app, &resp);
|
context_menu(app, &resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
|
||||||
|
/// top-left corner.
|
||||||
|
fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region {
|
||||||
|
let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 };
|
||||||
|
let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor());
|
||||||
|
let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil());
|
||||||
|
let lim = i32::MAX as f64 / 4.0;
|
||||||
|
let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim));
|
||||||
|
deckcraft_render::Region {
|
||||||
|
x: x0 as i32,
|
||||||
|
y: y0 as i32,
|
||||||
|
w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32,
|
||||||
|
h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||||
let Some(d) = app.session.active() else { return };
|
let Some(d) = app.session.active() else { return };
|
||||||
|
|
@ -240,14 +268,17 @@ fn master_tex(
|
||||||
master: usize,
|
master: usize,
|
||||||
layout: Option<usize>,
|
layout: Option<usize>,
|
||||||
px: (u32, u32),
|
px: (u32, u32),
|
||||||
) -> Option<egui::TextureHandle> {
|
visible: deckcraft_render::Region,
|
||||||
|
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||||
|
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||||
|
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
|
||||||
let img = deckcraft_render::render_layout(
|
let img = deckcraft_render::render_layout(
|
||||||
doc,
|
doc,
|
||||||
master,
|
master,
|
||||||
layout,
|
layout,
|
||||||
&deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
|
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||||
);
|
);
|
||||||
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
|
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,8 @@ pub struct SlideApp {
|
||||||
pub canvas_rect: Option<egui::Rect>,
|
pub canvas_rect: Option<egui::Rect>,
|
||||||
pub canvas_scale: f32,
|
pub canvas_scale: f32,
|
||||||
pub slide_rect: Option<egui::Rect>,
|
pub slide_rect: Option<egui::Rect>,
|
||||||
|
/// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen.
|
||||||
|
pub canvas_region: Option<egui::Rect>,
|
||||||
pub palette: Option<(String, usize)>,
|
pub palette: Option<(String, usize)>,
|
||||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||||
pub integrated_titlebar: bool,
|
pub integrated_titlebar: bool,
|
||||||
|
|
@ -236,6 +238,7 @@ impl SlideApp {
|
||||||
canvas_rect: None,
|
canvas_rect: None,
|
||||||
canvas_scale: 1.0,
|
canvas_scale: 1.0,
|
||||||
slide_rect: None,
|
slide_rect: None,
|
||||||
|
canvas_region: None,
|
||||||
palette: None,
|
palette: None,
|
||||||
integrated_titlebar: false,
|
integrated_titlebar: false,
|
||||||
home: None,
|
home: None,
|
||||||
|
|
@ -978,4 +981,107 @@ mod tests {
|
||||||
app.run("app.home", json!({})).unwrap();
|
app.run("app.home", json!({})).unwrap();
|
||||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||||
}
|
}
|
||||||
|
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||||
|
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SlideApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
SlideApp::new(Session::with_new(), Services::default()),
|
||||||
|
);
|
||||||
|
harness.run_steps(2);
|
||||||
|
let app = harness.state_mut();
|
||||||
|
// Zoomed out, the canvas shows plenty of pasteboard around the slide.
|
||||||
|
app.ui.zoom = Some(0.4);
|
||||||
|
app.session.prefs.snap_to_grid = false;
|
||||||
|
app.session.prefs.smart_guides = false;
|
||||||
|
harness
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(app: &SlideApp) -> Vec<u64> {
|
||||||
|
app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_x(app: &SlideApp, id: u64) -> f64 {
|
||||||
|
let st = app.session.active().unwrap();
|
||||||
|
st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where
|
||||||
|
/// they can be selected and moved.
|
||||||
|
#[test]
|
||||||
|
fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() {
|
||||||
|
let mut harness = canvas_harness();
|
||||||
|
let off =
|
||||||
|
harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||||
|
harness.run_steps(3);
|
||||||
|
|
||||||
|
let app = harness.state();
|
||||||
|
let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale);
|
||||||
|
// The texture reaches past the slide's right edge to the shape's, and no further left/up.
|
||||||
|
assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}");
|
||||||
|
assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}");
|
||||||
|
let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k);
|
||||||
|
assert!(app.canvas_rect.unwrap().contains(centre));
|
||||||
|
|
||||||
|
// Drawn at full strength on the pasteboard (when a GPU renderer is available here).
|
||||||
|
if let Ok(img) = harness.render() {
|
||||||
|
let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0;
|
||||||
|
let shape = px(centre);
|
||||||
|
let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k));
|
||||||
|
let doc = &harness.state().session.active().unwrap().doc;
|
||||||
|
let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||||
|
let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4;
|
||||||
|
assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}");
|
||||||
|
assert_ne!(shape, board);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click selects it.
|
||||||
|
harness.hover_at(centre);
|
||||||
|
harness.run_steps(1);
|
||||||
|
harness.drag_at(centre);
|
||||||
|
harness.run_steps(1);
|
||||||
|
harness.drop_at(centre);
|
||||||
|
harness.run_steps(2);
|
||||||
|
assert_eq!(selected(harness.state()), vec![off]);
|
||||||
|
|
||||||
|
// Drag moves it, further off the slide.
|
||||||
|
let to = centre + egui::vec2(40.0, 0.0);
|
||||||
|
harness.hover_at(centre);
|
||||||
|
harness.run_steps(1);
|
||||||
|
harness.drag_at(centre);
|
||||||
|
harness.run_steps(1);
|
||||||
|
for i in 1..=4 {
|
||||||
|
harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0));
|
||||||
|
harness.run_steps(1);
|
||||||
|
}
|
||||||
|
harness.drop_at(to);
|
||||||
|
harness.run_steps(2);
|
||||||
|
let x = shape_x(harness.state(), off);
|
||||||
|
assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}");
|
||||||
|
// The pasteboard followed it.
|
||||||
|
let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap());
|
||||||
|
assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}");
|
||||||
|
|
||||||
|
// Idle frames don't render the canvas again.
|
||||||
|
let n = harness.state().textures.canvas_renders;
|
||||||
|
harness.run_steps(3);
|
||||||
|
assert_eq!(harness.state().textures.canvas_renders, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A slide with nothing off it renders just the slide, as before.
|
||||||
|
#[test]
|
||||||
|
fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() {
|
||||||
|
let mut harness = canvas_harness();
|
||||||
|
harness.run_steps(3);
|
||||||
|
let app = harness.state();
|
||||||
|
let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap());
|
||||||
|
assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}");
|
||||||
|
let n = app.textures.canvas_renders;
|
||||||
|
harness.run_steps(3);
|
||||||
|
assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
|
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
|
||||||
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
|
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
|
||||||
|
//!
|
||||||
|
//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is
|
||||||
|
//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use deckcraft_geom::Rect;
|
||||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||||
use deckcraft_render::{Image, RenderOpts};
|
use deckcraft_render::{Image, Region, RenderOpts};
|
||||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
|
|
@ -15,11 +19,15 @@ struct Key {
|
||||||
size: (u32, u32),
|
size: (u32, u32),
|
||||||
edit: bool,
|
edit: bool,
|
||||||
extra: u64,
|
extra: u64,
|
||||||
|
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||||
|
want: Region,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Entry {
|
struct Entry {
|
||||||
key: Key,
|
key: Key,
|
||||||
tex: TextureHandle,
|
tex: TextureHandle,
|
||||||
|
/// The region the texture covers (at least `key.want`).
|
||||||
|
region: Region,
|
||||||
// Keep the Arcs alive so their addresses can't be reused while cached.
|
// Keep the Arcs alive so their addresses can't be reused while cached.
|
||||||
_slide: Arc<Slide>,
|
_slide: Arc<Slide>,
|
||||||
_master: Option<Arc<Master>>,
|
_master: Option<Arc<Master>>,
|
||||||
|
|
@ -29,6 +37,14 @@ struct Entry {
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Textures {
|
pub struct Textures {
|
||||||
canvas: Option<Entry>,
|
canvas: Option<Entry>,
|
||||||
|
/// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s
|
||||||
|
/// held (kept alive so their addresses can't be reused).
|
||||||
|
estimate: Option<(Arc<Slide>, Option<Arc<Master>>, Rect)>,
|
||||||
|
/// How far the last canvas render of a slide (by index) actually drew: text spilling out of
|
||||||
|
/// its box shows there.
|
||||||
|
drawn: Option<(usize, Rect)>,
|
||||||
|
/// Canvas renders so far (tests, performance checks).
|
||||||
|
pub canvas_renders: u64,
|
||||||
thumbs: HashMap<usize, Entry>,
|
thumbs: HashMap<usize, Entry>,
|
||||||
clock: u64,
|
clock: u64,
|
||||||
pub last_render_ms: f64,
|
pub last_render_ms: f64,
|
||||||
|
|
@ -55,41 +71,84 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn threads() -> u16 {
|
fn threads() -> u16 {
|
||||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
|
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Textures {
|
impl Textures {
|
||||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||||
|
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||||
|
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||||
|
/// GIF frames.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn slide(
|
pub fn slide(
|
||||||
&mut self,
|
&mut self,
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
p: &Presentation,
|
p: &Presentation,
|
||||||
index: usize,
|
index: usize,
|
||||||
size: (u32, u32),
|
size: (u32, u32),
|
||||||
|
visible: Region,
|
||||||
edit: bool,
|
edit: bool,
|
||||||
extra: u64,
|
extra: u64,
|
||||||
grayscale: bool,
|
grayscale: bool,
|
||||||
gif_times: &[(ShapeId, f64)],
|
gif_times: &[(ShapeId, f64)],
|
||||||
) -> Option<TextureHandle> {
|
) -> Option<(TextureHandle, Region)> {
|
||||||
let slide = p.slides.get(index)?.clone();
|
let slide = p.slides.get(index)?.clone();
|
||||||
let master = master_of(p, &slide);
|
let master = master_of(p, &slide);
|
||||||
|
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||||
|
let estimate = match &self.estimate {
|
||||||
|
Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r,
|
||||||
|
_ => {
|
||||||
|
let r = deckcraft_render::slide_extent(p, index);
|
||||||
|
self.estimate = Some((slide.clone(), master.clone(), r));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let reach = match self.drawn {
|
||||||
|
Some((i, d)) if i == index => estimate.union(d),
|
||||||
|
_ => estimate,
|
||||||
|
};
|
||||||
|
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
|
||||||
let key = Key {
|
let key = Key {
|
||||||
slide: Arc::as_ptr(&slide) as usize,
|
slide: Arc::as_ptr(&slide) as usize,
|
||||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||||
size,
|
size,
|
||||||
edit,
|
edit,
|
||||||
extra: extra ^ grayscale as u64,
|
extra: extra ^ grayscale as u64,
|
||||||
|
want,
|
||||||
};
|
};
|
||||||
if let Some(e) = &self.canvas
|
if let Some(e) = &self.canvas
|
||||||
&& e.key == key
|
&& e.key == key
|
||||||
{
|
{
|
||||||
return Some(e.tex.clone());
|
return Some((e.tex.clone(), e.region));
|
||||||
}
|
}
|
||||||
let t0 = crate::now_ms();
|
let t0 = crate::now_ms();
|
||||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
let render = |region: Region| {
|
||||||
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
|
let opts =
|
||||||
let img = deckcraft_render::render_slide(p, index, &opts);
|
RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||||
|
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||||
|
};
|
||||||
|
let (mut img, drawn) = render(want);
|
||||||
|
self.canvas_renders += 1;
|
||||||
|
let mut region = want;
|
||||||
|
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
|
||||||
|
// region it calls for is known (and is next frame's key): render again only if it shows
|
||||||
|
// more than this render covered.
|
||||||
|
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
|
||||||
|
if !region.contains(&key.want) {
|
||||||
|
region = key.want;
|
||||||
|
img = render(region).0;
|
||||||
|
self.canvas_renders += 1;
|
||||||
|
}
|
||||||
|
self.drawn = Some((index, drawn));
|
||||||
self.last_render_ms = crate::now_ms() - t0;
|
self.last_render_ms = crate::now_ms() - t0;
|
||||||
let ci = to_color_image(&img, grayscale);
|
let ci = to_color_image(&img, grayscale);
|
||||||
let tex = match self.canvas.take() {
|
let tex = match self.canvas.take() {
|
||||||
|
|
@ -99,13 +158,15 @@ impl Textures {
|
||||||
}
|
}
|
||||||
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
||||||
};
|
};
|
||||||
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
|
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||||
Some(tex)
|
Some((tex, region))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
|
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
|
||||||
pub fn invalidate(&mut self) {
|
pub fn invalidate(&mut self) {
|
||||||
self.canvas = None;
|
self.canvas = None;
|
||||||
|
self.estimate = None;
|
||||||
|
self.drawn = None;
|
||||||
self.thumbs.clear();
|
self.thumbs.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +182,7 @@ impl Textures {
|
||||||
size: (width_px, h),
|
size: (width_px, h),
|
||||||
edit: false,
|
edit: false,
|
||||||
extra: 0,
|
extra: 0,
|
||||||
|
want: Region::slide(width_px, h),
|
||||||
};
|
};
|
||||||
self.clock += 1;
|
self.clock += 1;
|
||||||
let clock = self.clock;
|
let clock = self.clock;
|
||||||
|
|
@ -139,7 +201,8 @@ impl Textures {
|
||||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
|
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
|
||||||
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
||||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock });
|
let region = key.want;
|
||||||
|
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||||
if self.thumbs.len() > 600 {
|
if self.thumbs.len() > 600 {
|
||||||
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
|
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
|
||||||
v.sort_by_key(|x| x.1);
|
v.sort_by_key(|x| x.1);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI parity with PowerPoint
|
# UI parity with PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; ribbon, menus, panes, dialogs and interaction compared with PowerPoint 16.113 observations in `plan/powerpoint/`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||||
|
|
@ -41,6 +41,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
||||||
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
||||||
|
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
|
||||||
| Smart guides (edges, centres, equal spacing) | yes | yes |
|
| Smart guides (edges, centres, equal spacing) | yes | yes |
|
||||||
| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle |
|
| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle |
|
||||||
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
|
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
|
||||||
|
|
@ -87,4 +88,5 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue