Canvas: objects off the slide show and can be grabbed around it, as in PowerPoint (#270) (#329)

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, with
their effects, master and layout graphics included; the slide show,
exports and thumbnails clip to the slide. DeckCraft clipped everything to
the slide in the editor too.

- render: `slide_extent`/`layout_extent` estimate how far a slide's
  drawing reaches (boxes, outlines, shadow, glow, reflection);
  `render_slide_drawn` also reports what a render actually drew (text
  spilling out of its box, table rows that grew); `pasteboard_region`
  picks the pixels the editor renders: the slide plus what reaches off
  it and is in view, rounded to 256 px tiles so scrolling rarely
  re-renders. A slide with nothing off it renders exactly as before.
- ui-egui: the canvas texture covers that region (slide and master
  views); it re-renders only when the slide, zoom or the region changes,
  and a second time only if text drew beyond the estimate.
- Hit testing, selection, marquee and moves already worked in slide
  space; tests now cover them off the slide.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 21:47:05 -04:00
1 parent 75bfd1da3e
commit f94f3c12d1
10 files changed
+758 -27

No files matched your search

+1
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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-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) |
+39
View File
@@ -567,3 +567,42 @@ fn inserted_hyperlinks_look_like_links() {
s.execute("format.underline", &json!({})).unwrap();
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]);
}
+329
View File
@@ -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);
}
}
+63 -7
View File
@@ -11,6 +11,7 @@ mod chart;
mod colr;
mod compound;
mod extent;
mod images;
mod metafile;
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_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 morph_path::PathMorph;
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
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
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 {
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
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
let Some(slide) = pres.slides.get(index) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
Ok(img) => img,
render_slide_drawn(pres, index, opts).0
}
/// [`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(_) => {
log::error!("rendering slide {index} panicked; showing a blank slide");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
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)
}
/// 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 {
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);
}
}
ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields),
ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h),
ShapeKind::Table(t) => {
// 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 } => {
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();
for (i, (px, py, _)) in sk.points.iter().enumerate() {
if i == 0 {
@@ -692,6 +740,8 @@ impl Renderer {
let (fill, fill_ph) = resolve::fill(rctx, s);
let (line, line_ph) = resolve::line(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 outline = geo.outline();
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
@@ -825,6 +875,12 @@ impl Renderer {
let tr = geo.text_rect;
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 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) {
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
+14 -1
View File
@@ -297,9 +297,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
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 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.
let style_bg = rctx
.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);
let _ = peniko::Fill::NonZero;
}
covered
}
+91
View File
@@ -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");
}
}
/// #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));
}
+39 -8
View File
@@ -1,6 +1,10 @@
//! 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
//! 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::{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);
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
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)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), 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, visible),
None => None,
};
if let Some(tex) = tex {
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
if let Some((tex, region)) = tex {
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;
media_frames(app, &painter, xf);
@@ -157,6 +169,22 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
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.
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
let Some(d) = app.session.active() else { return };
@@ -240,14 +268,17 @@ fn master_tex(
master: usize,
layout: Option<usize>,
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(
doc,
master,
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) {
+106
View File
@@ -193,6 +193,8 @@ pub struct SlideApp {
pub canvas_rect: Option<egui::Rect>,
pub canvas_scale: f32,
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)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
@@ -236,6 +238,7 @@ impl SlideApp {
canvas_rect: None,
canvas_scale: 1.0,
slide_rect: None,
canvas_region: None,
palette: None,
integrated_titlebar: false,
home: None,
@@ -978,4 +981,107 @@ mod tests {
app.run("app.home", json!({})).unwrap();
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");
}
}
+73 -10
View File
@@ -1,11 +1,15 @@
//! 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).
//!
//! 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::sync::Arc;
use deckcraft_geom::Rect;
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
use deckcraft_render::{Image, RenderOpts};
use deckcraft_render::{Image, Region, RenderOpts};
use egui::{ColorImage, TextureHandle, TextureOptions};
#[derive(Clone, PartialEq)]
@@ -15,11 +19,15 @@ struct Key {
size: (u32, u32),
edit: bool,
extra: u64,
/// The pasteboard region the slide's drawing calls for (canvas only).
want: Region,
}
struct Entry {
key: Key,
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.
_slide: Arc<Slide>,
_master: Option<Arc<Master>>,
@@ -29,6 +37,14 @@ struct Entry {
#[derive(Default)]
pub struct Textures {
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>,
clock: u64,
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()
}
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 {
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
}
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(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
size: (u32, u32),
visible: Region,
edit: bool,
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
) -> Option<TextureHandle> {
) -> Option<(TextureHandle, Region)> {
let slide = p.slides.get(index)?.clone();
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 {
slide: Arc::as_ptr(&slide) as usize,
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size,
edit,
extra: extra ^ grayscale as u64,
want,
};
if let Some(e) = &self.canvas
&& e.key == key
{
return Some(e.tex.clone());
return Some((e.tex.clone(), e.region));
}
let t0 = crate::now_ms();
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
let img = deckcraft_render::render_slide(p, index, &opts);
let render = |region: Region| {
let 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;
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {
@@ -99,13 +158,15 @@ impl Textures {
}
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
};
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
Some(tex)
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
Some((tex, region))
}
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
pub fn invalidate(&mut self) {
self.canvas = None;
self.estimate = None;
self.drawn = None;
self.thumbs.clear();
}
@@ -121,6 +182,7 @@ impl Textures {
size: (width_px, h),
edit: false,
extra: 0,
want: Region::slide(width_px, h),
};
self.clock += 1;
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 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.
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 {
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
v.sort_by_key(|x| x.1);
+3 -1
View File
@@ -1,6 +1,6 @@
# 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,
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 |
| 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 |
| 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 |
@@ -87,4 +88,5 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| 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 |