diff --git a/ROADMAP.md b/ROADMAP.md index c74bbda..bc987af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5703730..2693853 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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![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![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![half]); +} diff --git a/crates/render/src/extent.rs b/crates/render/src/extent.rs new file mode 100644 index 0000000..d5d9a27 --- /dev/null +++ b/crates/render/src/extent.rs @@ -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 { + 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, 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) -> 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); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index aae393f..8f88024 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, 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, } 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), diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 61eea5b..685df6f 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -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 { 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 } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ee3b704..d9a5883 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d267f76..886694f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, ×) + 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::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, px: (u32, u32), -) -> Option { + 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) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a02218c..0bd854b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -193,6 +193,8 @@ pub struct SlideApp { pub canvas_rect: Option, pub canvas_scale: f32, pub slide_rect: Option, + /// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen. + pub canvas_region: Option, 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 { + 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"); + } } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 84cf275..4049842 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -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, _master: Option>, @@ -29,6 +37,14 @@ struct Entry { #[derive(Default)] pub struct Textures { canvas: Option, + /// 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, Option>, 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, clock: u64, pub last_render_ms: f64, @@ -55,41 +71,84 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option> { p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() } +fn same(a: &Option>, b: &Option>) -> 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 { + ) -> 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); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5c7c50b..07ae91c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |