From d741144240a6ebde528958ed2cb0cb69e9e568c3 Mon Sep 17 00:00:00 2001 From: skylar Date: Mon, 26 Oct 2026 14:13:05 -0600 Subject: [PATCH 01/90] ci: require nextest 0.9.150 Use the fixed release for GHSA-vc25-qg58-w57c. --- .config/nextest.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" From f987fe63a77023dd07992f982c3a5963a053a75e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:39 +0000 Subject: [PATCH 02/90] refactor(engine): move cmd/mod.rs to cmd.rs --- crates/engine/src/{cmd/mod.rs => cmd.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{cmd/mod.rs => cmd.rs} (100%) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs From b7a343d9cf5f49e188ca2a54c59e7891f2a27a3a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:40 +0000 Subject: [PATCH 03/90] refactor(pptx): move read/mod.rs to read.rs --- crates/pptx/src/{read/mod.rs => read.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/pptx/src/{read/mod.rs => read.rs} (100%) diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read.rs similarity index 100% rename from crates/pptx/src/read/mod.rs rename to crates/pptx/src/read.rs From 1a2f8c0b0fb2b44a1697959f0912bb9b5697bbb2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:40 +0000 Subject: [PATCH 04/90] refactor(pptx): move write/mod.rs to write.rs --- crates/pptx/src/{write/mod.rs => write.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/pptx/src/{write/mod.rs => write.rs} (100%) diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write.rs similarity index 100% rename from crates/pptx/src/write/mod.rs rename to crates/pptx/src/write.rs From 2fb85dfa2f14e94d335ee8ce84ab8edc163b6c85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:40 +0000 Subject: [PATCH 05/90] refactor(opus): move celt/mod.rs to celt.rs --- crates/opus/src/{celt/mod.rs => celt.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/opus/src/{celt/mod.rs => celt.rs} (100%) diff --git a/crates/opus/src/celt/mod.rs b/crates/opus/src/celt.rs similarity index 100% rename from crates/opus/src/celt/mod.rs rename to crates/opus/src/celt.rs From 9ce54ad73fd171625f9b05a4e56f76638fe1850b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:41 +0000 Subject: [PATCH 06/90] refactor(opus): move silk/mod.rs to silk.rs --- crates/opus/src/{silk/mod.rs => silk.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/opus/src/{silk/mod.rs => silk.rs} (100%) diff --git a/crates/opus/src/silk/mod.rs b/crates/opus/src/silk.rs similarity index 100% rename from crates/opus/src/silk/mod.rs rename to crates/opus/src/silk.rs From 36f454b60f4cf62e5f1039a904698438d0886372 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:16 +0000 Subject: [PATCH 07/90] refactor(ui-egui): move capture tests to capture/tests.rs --- crates/ui-egui/src/capture.rs | 407 +--------------------------- crates/ui-egui/src/capture/tests.rs | 404 +++++++++++++++++++++++++++ 2 files changed, 405 insertions(+), 406 deletions(-) create mode 100644 crates/ui-egui/src/capture/tests.rs diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index 34c036cde..b94e5c2cc 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -592,409 +592,4 @@ impl Drop for Capture { } #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use super::*; - - /// Test helper: build a solid red `ColorImage` of the requested size. - fn image(w: usize, h: usize) -> Arc { - Arc::new(ColorImage::new([w, h], vec![egui::Color32::RED; w * h])) - } - - /// Test helper: admit one `ui.screenshot` request and return its token and reply receiver. - fn setup(cap: &mut Capture, now: web_time::Instant, path: Option<&str>) -> (u64, std::sync::mpsc::Receiver) { - let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); - cap.admit(path.map(str::to_string), req.reply.clone(), Some(7), now).unwrap(); - (cap.requests.last().unwrap().token, rx) - } - - /// Filling the registry to `MAX_REQUESTS` reports full; cancelling after a late completion - /// leaves no image. - #[test] - fn admission_is_bounded_and_late_cancel_keeps_no_image() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - for _ in 0..MAX_REQUESTS { - setup(&mut cap, now, None); - } - assert!(cap.is_full(), "four admitted requests fill the registry"); - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); - slot.cancel(); - assert!(matches!(slot.take(), SlotResult::Cancelled), "a completion after cancel retains nothing"); - } - - /// A completion carrying a different request token cannot complete the tracked slot. - #[test] - fn one_shot_token_correlation_ignores_a_foreign_completion() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let (token, _rx) = setup(&mut cap, now, Some("first.png")); - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(token + 1, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); - assert!(matches!(slot.take(), SlotResult::Pending), "another token cannot complete this slot"); - slot.complete(token, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); - assert!(matches!(slot.take(), SlotResult::Ready(_))); - } - - /// Empty images are rejected; nonzero images are retained with their size. - #[test] - fn empty_images_are_rejected_and_other_sizes_are_kept_for_the_drain() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let (token, _rx) = setup(&mut cap, now, None); - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(0, 2)); - assert!(matches!(slot.take(), SlotResult::Rejected(_)), "zero width"); - slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(3, 3)); - let taken = slot.take(); - assert!(matches!(&taken, SlotResult::Ready(image) if image.size == [3, 3]), "the actual size reaches drain"); - } - - /// A smaller image from a resize that has not been observed yet must not be saved just - /// because it fits the byte bound; drain compares the exact pixel size and retries. - #[test] - fn an_image_of_a_different_size_retries_instead_of_saving() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(4.0, 4.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - cap.ui_passes = 10; - let (_token, rx) = setup(&mut cap, now, Some("resized.png")); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 64; - cap.reserved = 64; - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); - let done = cap.drain(now, Some(7)); - assert!(done.is_empty(), "a stale-size image is not written"); - assert_eq!(cap.pending(), 1, "the request is retried"); - assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); - assert!(rx.try_recv().is_err(), "no premature error reply"); - } - - /// A completion that is ready when the deadline has just passed is still a valid frame. - #[test] - fn a_ready_image_wins_over_the_deadline() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - let (_token, _rx) = setup(&mut cap, now, Some("late.png")); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 16; - cap.reserved = 16; - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); - let done = cap.drain(now + DEADLINE + Duration::from_millis(1), Some(7)); - assert_eq!(done.len(), 1, "the completed frame is delivered"); - assert_eq!(done[0].path.as_deref(), Some("late.png")); - assert_eq!(done[0].reserved, 16); - assert_eq!(cap.pending(), 0); - assert_eq!(cap.reserved_bytes(), 16, "the encode reservation is still held"); - cap.release(done[0].reserved); - assert_eq!(cap.reserved_bytes(), 0); - } - - /// Fractional DPI rounds the framebuffer size once, on the reserved geometry only. - #[test] - fn fractional_dpi_rounds_to_the_reserved_pixel_size() { - let g = Geometry { size: Vec2::new(101.0, 51.0), pixels_per_point: 1.5, viewport: ViewportId::ROOT, epoch: 1 }; - assert_eq!(g.pixel_size(), Some([152, 77])); - let accepted = |image: [usize; 2]| g.pixel_size() == Some(image); - assert!(accepted([152, 77])); - assert!(!accepted([151, 76]), "a different framebuffer size is a resize, not a fit"); - } - - /// Pixel-memory budgets use checked geometry and reject zero, NaN, or overflowing sizes. - #[test] - fn budgets_use_checked_geometry_and_reject_zero_or_nan_sizes() { - let g = Geometry { size: Vec2::new(100.0, 50.0), pixels_per_point: 2.0, viewport: ViewportId::ROOT, epoch: 1 }; - assert_eq!(g.pixel_size(), Some([200, 100])); - assert_eq!(g.image_bytes(), Some(200 * 100 * 4)); - assert_eq!(Geometry { size: Vec2::new(0.0, 50.0), ..g }.image_bytes(), None); - assert_eq!(Geometry { size: Vec2::new(f32::NAN, 50.0), ..g }.image_bytes(), None); - assert_eq!(Geometry { size: Vec2::new(f32::MAX, f32::MAX), pixels_per_point: f32::MAX, ..g }.image_bytes(), None); - } - - /// A changed geometry epoch retries instead of saving a stale image. - #[test] - fn a_geometry_epoch_change_retries_instead_of_saving() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - // Fake a settled UI pass and issue the request against it. - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 4 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - cap.ui_passes = 10; - let (_token, _rx) = setup(&mut cap, now, Some("retry.png")); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 16; - cap.reserved = 16; - // The geometry changed before the completion arrived. - cap.geometry = Some(Geometry { epoch: 5, ..geometry }); - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); - let done = cap.drain(now, Some(7)); - assert!(done.is_empty(), "stale geometry is not written"); - assert_eq!(cap.pending(), 1, "the request is retried"); - assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); - assert!(cap.requests[0].issued.is_none()); - } - - /// A document change after admission fails the request before any file is written. - #[test] - fn a_document_change_fails_before_writing() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - let (_token, rx) = setup(&mut cap, now, Some("doc.png")); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 16; - cap.reserved = 16; - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); - let done = cap.drain(now, Some(8)); - assert!(done.is_empty()); - assert_eq!(cap.pending(), 0); - let reply = rx.recv().unwrap(); - assert_eq!(reply["ok"], false); - } - - /// A timed-out capture replies with failure and releases its reservation. - #[test] - fn timeout_replies_once_and_releases_the_reservation() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - let (_token, rx) = setup(&mut cap, now, None); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 16; - cap.reserved = 16; - let done = cap.drain(now + DEADLINE, Some(7)); - assert!(done.is_empty()); - assert_eq!(cap.pending(), 0); - assert_eq!(cap.reserved_bytes(), 0); - assert_eq!(rx.recv().unwrap()["ok"], false); - } - - /// An out-of-order completion is written to the path belonging to its own token. - #[test] - fn distinct_tokens_keep_distinct_paths_even_when_completed_out_of_order() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - setup(&mut cap, now, Some("a.png")); - setup(&mut cap, now, Some("b.png")); - let plan = MemoryPlan::for_image(2, 2, false).expect("path capture plan"); - for req in &mut cap.requests { - req.issued = Some(Issued { geometry }); - req.reservation = plan.peak; - } - cap.reserved = 2 * plan.peak; - let bounds = CaptureBounds { max_image_bytes: plan.color_image }; - let second = Arc::clone(&cap.requests[1].slot); - second.complete(cap.requests[1].token, bounds, image(2, 2)); - let done = cap.drain(now, Some(7)); - assert_eq!(done.len(), 1); - assert_eq!(done[0].path.as_deref(), Some("b.png")); - assert_eq!(cap.pending(), 1, "the unfinished first request continues"); - assert_eq!(cap.reserved_bytes(), 2 * plan.peak, "completed images stay reserved through encoding"); - cap.release(done[0].reserved); - assert_eq!(cap.reserved_bytes(), plan.peak, "only the saved second capture is released"); - let first = Arc::clone(&cap.requests[0].slot); - first.complete(cap.requests[0].token, bounds, image(2, 2)); - let done = cap.drain(now, Some(7)); - assert_eq!(done.len(), 1); - assert_eq!(done[0].path.as_deref(), Some("a.png")); - cap.release(done[0].reserved); - assert_eq!(cap.reserved_bytes(), 0); - } - - /// The reservation counts the callback image, the RGBA copy, the encoder's straight-alpha - /// copy, the PNG bound (raw + row filters) and (only for an inline reply) its base64 text. - #[test] - fn memory_plan_counts_image_rgba_png_and_inline_base64() { - let path = MemoryPlan::for_image(2, 2, false).expect("path plan"); - assert_eq!(path.color_image, 16); - assert_eq!(path.rgba, 16); - assert_eq!(path.encoder_copy, 16); - assert_eq!(path.png, 16 + 2 + 1024, "raw, one filter byte per row and encoder slack"); - assert_eq!(path.inline, 0, "a path reply does not hold base64"); - assert_eq!(path.peak, 3 * 16 + path.png); - - let inline = MemoryPlan::for_image(2, 2, true).expect("inline plan"); - assert_eq!(inline.png, path.png); - assert_eq!(inline.inline, path.png.div_ceil(3) * 4); - assert_eq!(inline.peak, path.peak + inline.inline); - - assert_eq!(MemoryPlan::for_image(0, 2, false), None, "zero width"); - assert_eq!(MemoryPlan::for_image(usize::MAX, 2, false), None, "overflowing width"); - assert_eq!(MemoryPlan::for_image(usize::MAX / 4, 4, false), None, "pixel-byte overflow"); - } - - /// Admission reserves the peak before the first UI pass can issue the screenshot, and a - /// second request that would cross the aggregate is refused without changing the total. - #[test] - fn admission_reserves_the_peak_and_refuses_the_one_that_would_exceed_it() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - let plan = MemoryPlan::for_geometry(geometry, true).expect("plan"); - cap.reserved = MAX_TOTAL_BYTES - plan.peak; - setup(&mut cap, now, None); - assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "the exact limit is admitted"); - let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); - assert!(cap.admit(None, req.reply, Some(7), now).is_err(), "one more peak does not fit"); - assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "a refused admission changes nothing"); - assert!(rx.try_recv().is_err(), "the caller sends the refusal reply"); - } - - /// A resize rebooks a queued (not in-flight) request at the new peak; the old reservation - /// is released first, so replacement never double-counts it. - #[test] - fn rebooking_at_a_new_geometry_replaces_the_old_reservation() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - setup(&mut cap, now, Some("resize.png")); - let small = MemoryPlan::for_geometry(geometry, false).expect("small plan"); - assert_eq!(cap.reserved_bytes(), small.peak); - let bigger = Geometry { size: Vec2::new(200.0, 200.0), ..geometry }; - cap.geometry = Some(bigger); - cap.rebook_unissued(); - let large = MemoryPlan::for_geometry(bigger, false).expect("large plan"); - assert_eq!(cap.reserved_bytes(), large.peak, "the new peak replaces the old one"); - assert_eq!(cap.requests[0].reservation, large.peak); - } - - /// The aggregate limit is inclusive: exactly 256 MiB is admitted; one byte more is refused. - /// Replacing a request's own reservation does not count it twice. - #[test] - fn aggregate_budget_accepts_the_limit_and_refuses_one_byte_over() { - assert_eq!(budget_total(MAX_TOTAL_BYTES - 16, 0, 16), Some(MAX_TOTAL_BYTES), "equality is allowed"); - assert_eq!(budget_total(MAX_TOTAL_BYTES - 15, 0, 16), None, "one byte over is refused"); - assert_eq!(budget_total(MAX_TOTAL_BYTES, 16, 16), Some(MAX_TOTAL_BYTES), "a replaced reservation is released first"); - } - - /// The callback checks the image against the reservation for the issued geometry before it - /// stores anything. An oversized frame is not retained and drain retries it until the - /// normal deadline, where it fails once. - #[test] - fn a_callback_image_over_the_reserved_geometry_is_not_retained() { - let now = web_time::Instant::now(); - let mut cap = Capture::default(); - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.settled_passes = SETTLED_PASSES; - cap.ui_passes = 10; - let (_token, rx) = setup(&mut cap, now, Some("huge.png")); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 16; - cap.reserved = 16; - let slot = Arc::clone(&cap.requests[0].slot); - let oversized = image(3, 3); - let weak = Arc::downgrade(&oversized); - assert!(slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, oversized.clone())); - drop(oversized); - assert!(weak.upgrade().is_none(), "the rejected pixels are released before the next pass"); - assert!(matches!(slot.take(), SlotResult::Retry(_)), "the oversized frame is retryable, not stored"); - - // Put the retry result back into the request and retry once without writing a file. - let slot = Arc::clone(&cap.requests[0].slot); - slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(3, 3)); - let done = cap.drain(now, Some(7)); - assert!(done.is_empty(), "an oversized image is never written"); - assert_eq!(cap.pending(), 1, "the request retries against fresh geometry"); - assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); - assert!(rx.try_recv().is_err(), "no premature error reply"); - - let done = cap.drain(now + DEADLINE, Some(7)); - assert!(done.is_empty()); - assert_eq!(cap.pending(), 0); - assert_eq!(cap.reserved_bytes(), 0); - assert_eq!(rx.recv().unwrap()["ok"], false, "the normal deadline still fails the request once"); - } - - /// A completion wakes the viewport that issued it, once, and only after the slot lock has - /// been released. A second clone of the one-shot callback cannot wake it again. - #[test] - fn a_completion_wakes_only_its_own_viewport_once() { - let ctx = Context::default(); - let viewport = ViewportId::from_hash_of("capture-wake"); - let slot = Arc::new(CaptureSlot::new(1)); - let weak = Arc::downgrade(&slot); - let (tx, rx) = std::sync::mpsc::channel(); - // A new Context starts with pending root paints. Establish the idle state first. - for _ in 0..3 { - ctx.run_ui(Default::default(), |_| {}).drop_without_applying_deltas(); - } - assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT)); - ctx.set_repaint_observer(move |info| { - let _ = tx.send(info.viewport_id); - }); - let callback = completion_callback(ctx.clone(), viewport, weak, 1, CaptureBounds { max_image_bytes: 16 }); - let second = callback.clone(); - callback.complete(image(2, 2)); - second.complete(image(2, 2)); - assert_eq!(rx.try_recv().ok(), Some(viewport), "the issuing viewport is woken"); - assert!(rx.try_recv().is_err(), "a one-shot completion wakes once"); - assert!(ctx.has_requested_repaint_for(&viewport)); - assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT), "another viewport is not woken"); - } - - /// A queued request needs a bounded hidden paint to reach issue. Once issued, the screenshot - /// command/callback own the wake and only the deadline timer remains, so a hidden window does - /// not repaint every 16 ms. - #[test] - fn hidden_paints_are_bounded_to_queued_requests_and_the_deadline() { - let now = web_time::Instant::now(); - let ctx = Context::default(); - let mut cap = Capture::default(); - setup(&mut cap, now, None); - - let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); - let queued = output.viewport_output.get(&ViewportId::ROOT).expect("root output"); - assert!( - queued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), - "a queued hidden request asks for a real bounded paint: {:?}", - queued.commands - ); - output.drop_without_applying_deltas(); - - let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; - cap.geometry = Some(geometry); - cap.requests[0].issued = Some(Issued { geometry }); - cap.requests[0].reservation = 16; - // Drain egui's initial/previous repaint passes before inspecting the future deadline. - for _ in 0..3 { - let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); - assert!(!output.viewport_output[&ViewportId::ROOT].commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden))); - output.drop_without_applying_deltas(); - } - let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); - let issued = output.viewport_output.get(&ViewportId::ROOT).expect("root output"); - assert!( - !issued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), - "an issued screenshot does not force hidden paints: {:?}", - issued.commands - ); - assert!( - issued.repaint_delay > Duration::from_secs(1) && issued.repaint_delay <= DEADLINE, - "only the normal deadline wake remains: {:?}", - issued.repaint_delay - ); - output.drop_without_applying_deltas(); - } -} +mod tests; diff --git a/crates/ui-egui/src/capture/tests.rs b/crates/ui-egui/src/capture/tests.rs new file mode 100644 index 000000000..54964611c --- /dev/null +++ b/crates/ui-egui/src/capture/tests.rs @@ -0,0 +1,404 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; + +/// Test helper: build a solid red `ColorImage` of the requested size. +fn image(w: usize, h: usize) -> Arc { + Arc::new(ColorImage::new([w, h], vec![egui::Color32::RED; w * h])) +} + +/// Test helper: admit one `ui.screenshot` request and return its token and reply receiver. +fn setup(cap: &mut Capture, now: web_time::Instant, path: Option<&str>) -> (u64, std::sync::mpsc::Receiver) { + let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); + cap.admit(path.map(str::to_string), req.reply.clone(), Some(7), now).unwrap(); + (cap.requests.last().unwrap().token, rx) +} + +/// Filling the registry to `MAX_REQUESTS` reports full; cancelling after a late completion +/// leaves no image. +#[test] +fn admission_is_bounded_and_late_cancel_keeps_no_image() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + for _ in 0..MAX_REQUESTS { + setup(&mut cap, now, None); + } + assert!(cap.is_full(), "four admitted requests fill the registry"); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); + slot.cancel(); + assert!(matches!(slot.take(), SlotResult::Cancelled), "a completion after cancel retains nothing"); +} + +/// A completion carrying a different request token cannot complete the tracked slot. +#[test] +fn one_shot_token_correlation_ignores_a_foreign_completion() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let (token, _rx) = setup(&mut cap, now, Some("first.png")); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(token + 1, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); + assert!(matches!(slot.take(), SlotResult::Pending), "another token cannot complete this slot"); + slot.complete(token, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); + assert!(matches!(slot.take(), SlotResult::Ready(_))); +} + +/// Empty images are rejected; nonzero images are retained with their size. +#[test] +fn empty_images_are_rejected_and_other_sizes_are_kept_for_the_drain() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let (token, _rx) = setup(&mut cap, now, None); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(0, 2)); + assert!(matches!(slot.take(), SlotResult::Rejected(_)), "zero width"); + slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(3, 3)); + let taken = slot.take(); + assert!(matches!(&taken, SlotResult::Ready(image) if image.size == [3, 3]), "the actual size reaches drain"); +} + +/// A smaller image from a resize that has not been observed yet must not be saved just +/// because it fits the byte bound; drain compares the exact pixel size and retries. +#[test] +fn an_image_of_a_different_size_retries_instead_of_saving() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(4.0, 4.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + cap.ui_passes = 10; + let (_token, rx) = setup(&mut cap, now, Some("resized.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 64; + cap.reserved = 64; + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert!(done.is_empty(), "a stale-size image is not written"); + assert_eq!(cap.pending(), 1, "the request is retried"); + assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); + assert!(rx.try_recv().is_err(), "no premature error reply"); +} + +/// A completion that is ready when the deadline has just passed is still a valid frame. +#[test] +fn a_ready_image_wins_over_the_deadline() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + let (_token, _rx) = setup(&mut cap, now, Some("late.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now + DEADLINE + Duration::from_millis(1), Some(7)); + assert_eq!(done.len(), 1, "the completed frame is delivered"); + assert_eq!(done[0].path.as_deref(), Some("late.png")); + assert_eq!(done[0].reserved, 16); + assert_eq!(cap.pending(), 0); + assert_eq!(cap.reserved_bytes(), 16, "the encode reservation is still held"); + cap.release(done[0].reserved); + assert_eq!(cap.reserved_bytes(), 0); +} + +/// Fractional DPI rounds the framebuffer size once, on the reserved geometry only. +#[test] +fn fractional_dpi_rounds_to_the_reserved_pixel_size() { + let g = Geometry { size: Vec2::new(101.0, 51.0), pixels_per_point: 1.5, viewport: ViewportId::ROOT, epoch: 1 }; + assert_eq!(g.pixel_size(), Some([152, 77])); + let accepted = |image: [usize; 2]| g.pixel_size() == Some(image); + assert!(accepted([152, 77])); + assert!(!accepted([151, 76]), "a different framebuffer size is a resize, not a fit"); +} + +/// Pixel-memory budgets use checked geometry and reject zero, NaN, or overflowing sizes. +#[test] +fn budgets_use_checked_geometry_and_reject_zero_or_nan_sizes() { + let g = Geometry { size: Vec2::new(100.0, 50.0), pixels_per_point: 2.0, viewport: ViewportId::ROOT, epoch: 1 }; + assert_eq!(g.pixel_size(), Some([200, 100])); + assert_eq!(g.image_bytes(), Some(200 * 100 * 4)); + assert_eq!(Geometry { size: Vec2::new(0.0, 50.0), ..g }.image_bytes(), None); + assert_eq!(Geometry { size: Vec2::new(f32::NAN, 50.0), ..g }.image_bytes(), None); + assert_eq!(Geometry { size: Vec2::new(f32::MAX, f32::MAX), pixels_per_point: f32::MAX, ..g }.image_bytes(), None); +} + +/// A changed geometry epoch retries instead of saving a stale image. +#[test] +fn a_geometry_epoch_change_retries_instead_of_saving() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + // Fake a settled UI pass and issue the request against it. + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 4 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + cap.ui_passes = 10; + let (_token, _rx) = setup(&mut cap, now, Some("retry.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + // The geometry changed before the completion arrived. + cap.geometry = Some(Geometry { epoch: 5, ..geometry }); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert!(done.is_empty(), "stale geometry is not written"); + assert_eq!(cap.pending(), 1, "the request is retried"); + assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); + assert!(cap.requests[0].issued.is_none()); +} + +/// A document change after admission fails the request before any file is written. +#[test] +fn a_document_change_fails_before_writing() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + let (_token, rx) = setup(&mut cap, now, Some("doc.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now, Some(8)); + assert!(done.is_empty()); + assert_eq!(cap.pending(), 0); + let reply = rx.recv().unwrap(); + assert_eq!(reply["ok"], false); +} + +/// A timed-out capture replies with failure and releases its reservation. +#[test] +fn timeout_replies_once_and_releases_the_reservation() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + let (_token, rx) = setup(&mut cap, now, None); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let done = cap.drain(now + DEADLINE, Some(7)); + assert!(done.is_empty()); + assert_eq!(cap.pending(), 0); + assert_eq!(cap.reserved_bytes(), 0); + assert_eq!(rx.recv().unwrap()["ok"], false); +} + +/// An out-of-order completion is written to the path belonging to its own token. +#[test] +fn distinct_tokens_keep_distinct_paths_even_when_completed_out_of_order() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + setup(&mut cap, now, Some("a.png")); + setup(&mut cap, now, Some("b.png")); + let plan = MemoryPlan::for_image(2, 2, false).expect("path capture plan"); + for req in &mut cap.requests { + req.issued = Some(Issued { geometry }); + req.reservation = plan.peak; + } + cap.reserved = 2 * plan.peak; + let bounds = CaptureBounds { max_image_bytes: plan.color_image }; + let second = Arc::clone(&cap.requests[1].slot); + second.complete(cap.requests[1].token, bounds, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert_eq!(done.len(), 1); + assert_eq!(done[0].path.as_deref(), Some("b.png")); + assert_eq!(cap.pending(), 1, "the unfinished first request continues"); + assert_eq!(cap.reserved_bytes(), 2 * plan.peak, "completed images stay reserved through encoding"); + cap.release(done[0].reserved); + assert_eq!(cap.reserved_bytes(), plan.peak, "only the saved second capture is released"); + let first = Arc::clone(&cap.requests[0].slot); + first.complete(cap.requests[0].token, bounds, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert_eq!(done.len(), 1); + assert_eq!(done[0].path.as_deref(), Some("a.png")); + cap.release(done[0].reserved); + assert_eq!(cap.reserved_bytes(), 0); +} + +/// The reservation counts the callback image, the RGBA copy, the encoder's straight-alpha +/// copy, the PNG bound (raw + row filters) and (only for an inline reply) its base64 text. +#[test] +fn memory_plan_counts_image_rgba_png_and_inline_base64() { + let path = MemoryPlan::for_image(2, 2, false).expect("path plan"); + assert_eq!(path.color_image, 16); + assert_eq!(path.rgba, 16); + assert_eq!(path.encoder_copy, 16); + assert_eq!(path.png, 16 + 2 + 1024, "raw, one filter byte per row and encoder slack"); + assert_eq!(path.inline, 0, "a path reply does not hold base64"); + assert_eq!(path.peak, 3 * 16 + path.png); + + let inline = MemoryPlan::for_image(2, 2, true).expect("inline plan"); + assert_eq!(inline.png, path.png); + assert_eq!(inline.inline, path.png.div_ceil(3) * 4); + assert_eq!(inline.peak, path.peak + inline.inline); + + assert_eq!(MemoryPlan::for_image(0, 2, false), None, "zero width"); + assert_eq!(MemoryPlan::for_image(usize::MAX, 2, false), None, "overflowing width"); + assert_eq!(MemoryPlan::for_image(usize::MAX / 4, 4, false), None, "pixel-byte overflow"); +} + +/// Admission reserves the peak before the first UI pass can issue the screenshot, and a +/// second request that would cross the aggregate is refused without changing the total. +#[test] +fn admission_reserves_the_peak_and_refuses_the_one_that_would_exceed_it() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + let plan = MemoryPlan::for_geometry(geometry, true).expect("plan"); + cap.reserved = MAX_TOTAL_BYTES - plan.peak; + setup(&mut cap, now, None); + assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "the exact limit is admitted"); + let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); + assert!(cap.admit(None, req.reply, Some(7), now).is_err(), "one more peak does not fit"); + assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "a refused admission changes nothing"); + assert!(rx.try_recv().is_err(), "the caller sends the refusal reply"); +} + +/// A resize rebooks a queued (not in-flight) request at the new peak; the old reservation +/// is released first, so replacement never double-counts it. +#[test] +fn rebooking_at_a_new_geometry_replaces_the_old_reservation() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + setup(&mut cap, now, Some("resize.png")); + let small = MemoryPlan::for_geometry(geometry, false).expect("small plan"); + assert_eq!(cap.reserved_bytes(), small.peak); + let bigger = Geometry { size: Vec2::new(200.0, 200.0), ..geometry }; + cap.geometry = Some(bigger); + cap.rebook_unissued(); + let large = MemoryPlan::for_geometry(bigger, false).expect("large plan"); + assert_eq!(cap.reserved_bytes(), large.peak, "the new peak replaces the old one"); + assert_eq!(cap.requests[0].reservation, large.peak); +} + +/// The aggregate limit is inclusive: exactly 256 MiB is admitted; one byte more is refused. +/// Replacing a request's own reservation does not count it twice. +#[test] +fn aggregate_budget_accepts_the_limit_and_refuses_one_byte_over() { + assert_eq!(budget_total(MAX_TOTAL_BYTES - 16, 0, 16), Some(MAX_TOTAL_BYTES), "equality is allowed"); + assert_eq!(budget_total(MAX_TOTAL_BYTES - 15, 0, 16), None, "one byte over is refused"); + assert_eq!(budget_total(MAX_TOTAL_BYTES, 16, 16), Some(MAX_TOTAL_BYTES), "a replaced reservation is released first"); +} + +/// The callback checks the image against the reservation for the issued geometry before it +/// stores anything. An oversized frame is not retained and drain retries it until the +/// normal deadline, where it fails once. +#[test] +fn a_callback_image_over_the_reserved_geometry_is_not_retained() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + cap.ui_passes = 10; + let (_token, rx) = setup(&mut cap, now, Some("huge.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let slot = Arc::clone(&cap.requests[0].slot); + let oversized = image(3, 3); + let weak = Arc::downgrade(&oversized); + assert!(slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, oversized.clone())); + drop(oversized); + assert!(weak.upgrade().is_none(), "the rejected pixels are released before the next pass"); + assert!(matches!(slot.take(), SlotResult::Retry(_)), "the oversized frame is retryable, not stored"); + + // Put the retry result back into the request and retry once without writing a file. + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(3, 3)); + let done = cap.drain(now, Some(7)); + assert!(done.is_empty(), "an oversized image is never written"); + assert_eq!(cap.pending(), 1, "the request retries against fresh geometry"); + assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); + assert!(rx.try_recv().is_err(), "no premature error reply"); + + let done = cap.drain(now + DEADLINE, Some(7)); + assert!(done.is_empty()); + assert_eq!(cap.pending(), 0); + assert_eq!(cap.reserved_bytes(), 0); + assert_eq!(rx.recv().unwrap()["ok"], false, "the normal deadline still fails the request once"); +} + +/// A completion wakes the viewport that issued it, once, and only after the slot lock has +/// been released. A second clone of the one-shot callback cannot wake it again. +#[test] +fn a_completion_wakes_only_its_own_viewport_once() { + let ctx = Context::default(); + let viewport = ViewportId::from_hash_of("capture-wake"); + let slot = Arc::new(CaptureSlot::new(1)); + let weak = Arc::downgrade(&slot); + let (tx, rx) = std::sync::mpsc::channel(); + // A new Context starts with pending root paints. Establish the idle state first. + for _ in 0..3 { + ctx.run_ui(Default::default(), |_| {}).drop_without_applying_deltas(); + } + assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT)); + ctx.set_repaint_observer(move |info| { + let _ = tx.send(info.viewport_id); + }); + let callback = completion_callback(ctx.clone(), viewport, weak, 1, CaptureBounds { max_image_bytes: 16 }); + let second = callback.clone(); + callback.complete(image(2, 2)); + second.complete(image(2, 2)); + assert_eq!(rx.try_recv().ok(), Some(viewport), "the issuing viewport is woken"); + assert!(rx.try_recv().is_err(), "a one-shot completion wakes once"); + assert!(ctx.has_requested_repaint_for(&viewport)); + assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT), "another viewport is not woken"); +} + +/// A queued request needs a bounded hidden paint to reach issue. Once issued, the screenshot +/// command/callback own the wake and only the deadline timer remains, so a hidden window does +/// not repaint every 16 ms. +#[test] +fn hidden_paints_are_bounded_to_queued_requests_and_the_deadline() { + let now = web_time::Instant::now(); + let ctx = Context::default(); + let mut cap = Capture::default(); + setup(&mut cap, now, None); + + let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); + let queued = output.viewport_output.get(&ViewportId::ROOT).expect("root output"); + assert!( + queued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), + "a queued hidden request asks for a real bounded paint: {:?}", + queued.commands + ); + output.drop_without_applying_deltas(); + + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + // Drain egui's initial/previous repaint passes before inspecting the future deadline. + for _ in 0..3 { + let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); + assert!(!output.viewport_output[&ViewportId::ROOT].commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden))); + output.drop_without_applying_deltas(); + } + let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); + let issued = output.viewport_output.get(&ViewportId::ROOT).expect("root output"); + assert!( + !issued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), + "an issued screenshot does not force hidden paints: {:?}", + issued.commands + ); + assert!( + issued.repaint_delay > Duration::from_secs(1) && issued.repaint_delay <= DEADLINE, + "only the normal deadline wake remains: {:?}", + issued.repaint_delay + ); + output.drop_without_applying_deltas(); +} From 33a0df30d6337a2d66f49629426d9c35dc55988a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:58 +0000 Subject: [PATCH 08/90] refactor(ui-egui): move lib.rs tests to tests.rs --- crates/ui-egui/src/lib.rs | 2376 +---------------------------------- crates/ui-egui/src/tests.rs | 2373 ++++++++++++++++++++++++++++++++++ 2 files changed, 2374 insertions(+), 2375 deletions(-) create mode 100644 crates/ui-egui/src/tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c5a7a681..060952bc4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3593,2378 +3593,4 @@ mod datetime_tests; mod inbox_tests; #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use super::*; - use egui_kittest::kittest::Queryable; - - fn shape_rot(app: &SlideApp) -> f64 { - let d = app.session.active().unwrap(); - let id = d.selection.shapes[0]; - d.shape(id).unwrap().xfrm.unwrap().rot - } - - /// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; - /// letting go is one undo step. Off the canvas it does nothing. - #[test] - fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { - 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 id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); - let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); - harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); - harness.run_steps(1); - let canvas = harness.state().canvas_rect.expect("canvas"); - let outside = egui::pos2(1.0, 1.0); - assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); - - harness.input_mut().events.push(egui::Event::PointerMoved(outside)); - harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); - harness.run_steps(1); - assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); - - harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); - harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); - harness.run_steps(1); - harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); - harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); - harness.run_steps(1); - assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); - harness.run_steps(1); - assert!(harness.state().session.active().unwrap().interaction.is_none()); - harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); - assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); - } - - /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). - #[test] - fn web_export_all_slides_gives_one_file_per_slide() { - let mut doc = deckcraft_model::Presentation::default(); - if let Some(first) = doc.slides.first().cloned() { - doc.slides.push(first.clone()); - doc.slides.push(first); - } - assert_eq!(doc.slides.len(), 3); - let all = web_image_files(&doc, 1, true, "Deck", "png", false); - let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); - assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); - let one = web_image_files(&doc, 1, false, "Deck", "png", false); - assert_eq!(one.len(), 1); - assert_eq!(one[0].0, "Deck.png"); - } - - /// The web PNG download honours "Transparent background" (#784): the slide background is - /// left out, so the corner is transparent; a JPEG download stays opaque. - #[test] - fn web_png_export_can_leave_the_background_transparent() { - use std::cell::RefCell; - use std::rc::Rc; - let got: Rc)>>> = Rc::default(); - let log = got.clone(); - let services = Services { - download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))), - ..Default::default() - }; - let mut app = SlideApp::new(Session::with_new(), services); - let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3]; - assert!(app.export_as("png", json!({}))); - assert!(app.export_as("png", json!({"transparentBackground": true}))); - assert!(app.export_as("jpeg", json!({"transparentBackground": true}))); - let got = got.borrow(); - assert_eq!(got.len(), 3); - assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default"); - assert_eq!(corner_alpha(&got[1].1), 0, "transparent background"); - assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); - } - - fn title_text(app: &SlideApp) -> String { - let d = app.session.active().unwrap(); - let sh = &d.doc.slides[0].shapes[0]; - sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() - } - - #[test] - fn quick_access_toolbar_adds_removes_resets_and_persists() { - 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 ids = |app: &SlideApp| app.ui.qat.iter().map(|q| q.id.clone()).collect::>(); - assert_eq!(ids(harness.state()), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); - let app = harness.state_mut(); - app.run("app.qat", json!({"add": "insert.table", "label": "Table", "icon": "table"})).unwrap(); - app.run("app.qat", json!({"add": "insert.table"})).unwrap(); - app.run("app.qat", json!({"add": "view.pane", "params": {"pane": "selection"}, "label": "Selection Pane"})).unwrap(); - assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); - app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); - assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); - assert_eq!(app.ui.qat[5].label, "Table"); - // Saved with the rest of the UI state and read back. - let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - assert_eq!(back.qat, app.ui.qat); - harness.run_steps(2); - let app = harness.state_mut(); - app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); - assert_eq!(app.ui.qat.len(), 6); - app.run("app.qat", json!({"reset": true})).unwrap(); - assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); - // An older settings file without the toolbar gets the defaults. - let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); - assert_eq!(old.qat.len(), 6); - } - - #[test] - fn opening_a_file_with_missing_fonts_shows_a_toast_once() { - use egui_kittest::kittest::Queryable; - let mut p = deckcraft_model::Presentation::default(); - let slide = std::sync::Arc::make_mut(&mut p.slides[0]); - let mut body = deckcraft_model::text::TextBody::from_text("Hello"); - body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into()); - slide.shapes[0].text = Some(body); - let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap()); - 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); - assert!(harness.state().toast.is_none(), "a new presentation has nothing to say"); - harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap(); - harness.run_steps(2); - let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default(); - assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}"); - assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some()); - // Closed, it stays closed for that document. - harness.get_by_label("Close").click(); - harness.run_steps(2); - assert!(harness.state().toast.is_none()); - harness.run_steps(2); - assert!(harness.state().toast.is_none()); - } - - /// #115: Change Picture replaced nothing — it inserted a second picture. - #[test] - fn change_picture_replaces_the_selected_picture() { - let svg = |c: &str| { - format!("").into_bytes() - }; - let media = |app: &SlideApp| { - let d = app.session.active().unwrap(); - let pics: Vec<_> = d.doc.slides[0] - .shapes - .iter() - .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) - .collect(); - assert_eq!(pics.len(), 1, "one picture, replaced in place"); - pics[0] - }; - let insert = |app: &mut SlideApp| { - let id = - app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() - ["id"] - .clone(); - app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); - media(app) - }; - - // Desktop: the file dialog returns a path. - let blue = svg("blue"); - let services = Services { - pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), - read: Some(Box::new(move |_: &str| Ok(blue.clone()))), - ..Default::default() - }; - let mut app = SlideApp::new(Session::with_new(), services); - let before = insert(&mut app); - app.run("app.changePictureDialog", json!({})).unwrap(); - assert_ne!(media(&app), before); - - // Web: the browser picker delivers the file through the inbox later. - let inbox = Inbox::default(); - let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let record = started.clone(); - let services = Services { - open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), - inbox: Some(inbox.clone()), - ..Default::default() - }; - let mut app = SlideApp::new(Session::with_new(), services); - let before = insert(&mut app); - app.run("app.changePictureDialog", json!({})).unwrap(); - let id = started.lock().unwrap()[0]; - inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") })); - app.drain_inbox(); - assert_ne!(media(&app), before); - } - - /// #17: Home created a new blank presentation instead of going to the start screen. - #[test] - fn home_shows_the_start_screen_over_the_open_presentation() { - 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()), - ); - // The app keeps requesting repaints, so step frames instead of run(). - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - - harness.state_mut().run("app.home", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().backstage_open()); - assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation"); - - // Typing on the start screen doesn't reach the presentation behind it; Escape goes back. - harness.event(egui::Event::Text("a".into())); - harness.key_press(egui::Key::Backspace); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), ""); - assert!(!harness.state().session.active().unwrap().is_dirty()); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(!harness.state().backstage_open()); - - // Home again toggles; picking a theme on the start screen (a new presentation) leaves it. - harness.state_mut().run("app.home", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().backstage_open()); - harness.state_mut().run("app.home", json!({})).unwrap(); - assert!(!harness.state().backstage_open()); - harness.state_mut().run("app.home", json!({"on": true})).unwrap(); - harness.state_mut().run("file.new", json!({})).unwrap(); - harness.run_steps(2); - assert!(!harness.state().backstage_open(), "a new presentation leaves Home"); - assert_eq!(harness.state().session.documents().len(), 2); - } - - /// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real - /// egui key events (Windows keys). - #[test] - fn ctrl_word_keys_edit_text() { - use egui::{Key, Modifiers}; - let mut session = Session::with_new(); - session.mac_keys = false; - session.prefs.autocorrect = false; - 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, Services::default()), - ); - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.event(egui::Event::Text("Hello, world. Foo bar".into())); - harness.run_steps(1); - assert_eq!(title_text(harness.state()), "Hello, world. Foo bar"); - let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap(); - let ctrl = Modifiers::COMMAND; - let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT; - - // Ctrl+Left stops at every word and punctuation run, as in PowerPoint. - let mut stops = vec![]; - for _ in 0..6 { - harness.key_press_modifiers(ctrl, Key::ArrowLeft); - harness.run_steps(1); - stops.push(caret(&harness).caret.1); - } - assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]); - harness.key_press_modifiers(ctrl, Key::ArrowRight); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (0, 5)); - - // Ctrl+Shift+Right selects to the next stops. - harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); - harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); - harness.run_steps(1); - let t = caret(&harness); - assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12))); - - // Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one - // before the caret. - harness.key_press_modifiers(ctrl, Key::Delete); - harness.run_steps(1); - assert_eq!(title_text(harness.state()), "Helloworld. Foo bar"); - harness.key_press(Key::End); - harness.key_press_modifiers(ctrl, Key::Backspace); - harness.run_steps(1); - assert_eq!(title_text(harness.state()), "Helloworld. Foo "); - - // Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends. - harness.key_press(Key::Enter); - harness.event(egui::Event::Text("Next".into())); - harness.key_press_modifiers(ctrl, Key::ArrowUp); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (1, 0)); - harness.key_press_modifiers(ctrl, Key::ArrowUp); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (0, 0)); - harness.key_press_modifiers(ctrl, Key::ArrowDown); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (1, 0)); - harness.key_press_modifiers(ctrl_shift, Key::Home); - harness.run_steps(1); - let t = caret(&harness); - assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0))); - - // Ctrl+Shift+> (egui reports the Period key) grows the selected text's font. - let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64(); - let before = size(&mut harness); - harness.key_press_modifiers(ctrl_shift, Key::Period); - harness.run_steps(1); - let after = size(&mut harness).unwrap(); - assert!(after > before.unwrap_or(0.0), "{before:?} → {after}"); - harness.key_press_modifiers(ctrl, Key::OpenBracket); - harness.run_steps(1); - assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks"); - } - - /// Export… had no visible control: only the command palette reached it. - #[test] - fn title_bar_export_opens_the_export_dialog() { - 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); - assert!(harness.state().dialog.is_none()); - // Quick access: Home, Save, Export, each 24 points wide with 2 between, from x = 10. - let pos = egui::pos2(10.0 + 2.0 * 26.0 + 12.0, ribbon::TITLE_H / 2.0); - harness.hover_at(pos); - harness.run_steps(1); - harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); - harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); - harness.run_steps(2); - assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); - } - - /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and - /// swallows the key press, so only the V release reaches the app. - #[test] - fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { - let session = Session::with_new(); - let png = { - let d = session.active().unwrap(); - deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 - }; - assert!(!png.is_empty()); - let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; - 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, services), - ); - harness.run_steps(2); - let pictures = |app: &SlideApp| { - let d = app.session.active().unwrap(); - d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() - }; - let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; - // A text paste (Paste event on the press) does not also paste the image on release. - harness.event(egui::Event::Paste("hello".into())); - harness.run_steps(2); - harness.event(v(false)); - harness.run_steps(2); - assert_eq!(pictures(harness.state()), 0); - // A V release without Command/Ctrl is not a paste. - harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); - harness.run_steps(2); - assert_eq!(pictures(harness.state()), 0); - // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. - harness.event(v(false)); - harness.run_steps(2); - assert_eq!(pictures(harness.state()), 1); - } - - /// #78: after Tab in a text box, typing and deleting no longer reached the text. - #[test] - fn typing_goes_on_after_tab_in_a_text_box() { - 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); - }, - { - let mut session = Session::with_new(); - session.prefs.autocorrect = false; - SlideApp::new(session, Services::default()) - }, - ); - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.event(egui::Event::Text("ab".into())); - harness.run_steps(2); - harness.key_press(egui::Key::Tab); - harness.run_steps(2); - harness.event(egui::Event::Text("c".into())); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "ab\tc"); - harness.key_press(egui::Key::Backspace); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "ab\t"); - assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); - // Tab at the start of a paragraph indents it, and typing goes on there too (#93). - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - harness.key_press(egui::Key::Tab); - harness.run_steps(2); - harness.event(egui::Event::Text("d".into())); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "ab\t\nd"); - let level = |h: &egui_kittest::Harness| { - h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level - }; - assert_eq!(level(&harness), 1); - } - - /// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus - /// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the - /// list could neither be typed into nor continued with Enter. - #[test] - fn tab_in_an_edited_list_keeps_the_keyboard() { - 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 title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.event(egui::Event::Text("one".into())); - harness.run_steps(2); - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - harness.key_press(egui::Key::Tab); // Increase List Level - harness.run_steps(2); - harness.event(egui::Event::Text("sub".into())); - harness.run_steps(2); - harness.key_press(egui::Key::Enter); // continues the list - harness.run_steps(2); - harness.event(egui::Event::Text("two".into())); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text"); - let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap(); - let levels: Vec = body.paragraphs.iter().map(|p| p.level).collect(); - assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level"); - } - - #[test] - fn home_needs_an_open_presentation() { - let mut app = SlideApp::new(Session::new(), Services::default()); - app.run("app.home", json!({})).unwrap(); - assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); - } - - /// View › New Window is a second window on the open presentation. Its slide is its own; - /// the editor's selection stays where it was. Closing the window leaves the document open. - #[test] - fn new_window_shows_the_same_document_on_its_own_slide() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("slide.go", &json!({"index": 1})).unwrap(); - let err = { - let mut empty = SlideApp::new(Session::new(), Services::default()); - empty.run("view.newWindow", json!({})).unwrap_err() - }; - assert!(err.contains("presentation"), "{err}"); - - let name = app.session.active().unwrap().title(); - let opened = app.run("view.newWindow", json!({})).unwrap(); - assert_eq!(opened["open"], true); - assert_eq!(opened["window"], 2); - assert_eq!(opened["slide"], 1); - assert_eq!(opened["title"], format!("{name}:2")); - assert_eq!(app.session.active().unwrap().selection.slide, 1); - - let moved = app.run("view.newWindow", json!({"delta": -1})).unwrap(); - assert_eq!(moved["slide"], 0); - assert_eq!(app.doc_window.as_ref().unwrap().slide, 0); - assert_eq!(app.session.active().unwrap().selection.slide, 1, "the editor stays on its slide"); - - let again = app.run("view.newWindow", json!({})).unwrap(); - assert_eq!(again["window"], 2); - assert_eq!(again["slide"], 0, "opening again keeps the window's slide"); - - app.session.execute("file.close", &json!({})).unwrap(); - doc_window::show(&mut app, &egui::Context::default()); - assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); - assert!(app.session.active().is_none()); - } - - #[test] - fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); - let v = - app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); - assert_eq!(v["spacing"], 72.0); - assert_eq!(v["snap"], false); - assert_eq!(v["smartGuides"], false); - assert_eq!(v["gridlines"], true); - assert_eq!(v["guides"], true); - assert!(app.dialog.is_none()); - assert!(app.ui.gridlines && app.ui.guides); - assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); - - app.run("view.gridOptions", json!({"snap": true})).unwrap(); - assert!(app.session.prefs.snap_to_grid); - assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); - assert!(app.dialog.is_none()); - - for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { - assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); - } - assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); - assert!(app.session.prefs.snap_to_grid); - - app.run("view.gridOptions", json!({})).unwrap(); - assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); - assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); - } - - #[test] - fn grid_options_dialog_lists_spacing_and_display() { - use egui_kittest::kittest::Queryable; - 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); - harness.state_mut().run("view.gridOptions", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.query_by_label_contains("Display grid on screen").is_some()); - assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); - assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); - let show_grid = harness - .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") - .next() - .unwrap_or_else(|| panic!("no grid checkbox")); - show_grid.click_accesskit(); - harness.run_steps(2); - harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); - assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); - assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); - - harness.state_mut().run("view.gridOptions", json!({})).unwrap(); - harness.run_steps(2); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") - .next() - .unwrap_or_else(|| panic!("no Cancel button")) - .click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); - assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); - assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); - } - - /// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog - /// changed while it was open; Done keeps it. - #[test] - fn preferences_cancel_restores_the_settings_done_keeps_them() { - use egui_kittest::kittest::Queryable; - 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 before = harness.state().session.prefs.clone(); - let dark_before = harness.state().ui.brightness; - let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| { - harness.state_mut().run("app.preferences", json!({})).unwrap(); - harness.run_steps(2); - for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] { - harness - .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label) - .next() - .unwrap_or_else(|| panic!("no {label} checkbox")) - .click_accesskit(); - harness.run_steps(2); - } - assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance"); - assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides); - }; - let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| { - let s = harness.state(); - assert!(s.dialog.is_none(), "{how} closes Preferences"); - assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance"); - assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides"); - assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid"); - }; - - change(&mut harness); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") - .next() - .unwrap_or_else(|| panic!("no Cancel button")) - .click_accesskit(); - harness.run_steps(2); - unchanged(&harness, "Cancel"); - - change(&mut harness); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - unchanged(&harness, "Esc"); - - change(&mut harness); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Done") - .next() - .unwrap_or_else(|| panic!("no Done button")) - .click_accesskit(); - harness.run_steps(2); - let s = harness.state(); - assert!(s.dialog.is_none(), "Done closes Preferences"); - assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance"); - assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides"); - assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid"); - } - - /// About dialog: Close and Cancel each close the dialog. - #[test] - fn about_dialog_close_and_cancel_close_it() { - use egui_kittest::kittest::Queryable; - 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); - harness.state_mut().run("app.about", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().dialog.is_some()); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") - .next() - .unwrap_or_else(|| panic!("no Cancel button in About dialog")) - .click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Cancel closes About dialog"); - - harness.state_mut().run("app.about", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().dialog.is_some()); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Close") - .next() - .unwrap_or_else(|| panic!("no Close button in About dialog")) - .click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Close closes About dialog"); - } - - 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 _gpu = crate::gpu_lock::gpu_lock(); - 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"); - } - - /// #431: the ribbon's Copy and Cut, the slide's context menu, the menus and the control - /// channel all run the commands; they put the text on the system clipboard, as Ctrl+C and - /// Ctrl+X do. - #[test] - fn copy_and_cut_commands_reach_the_system_clipboard() { - 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 copied = |h: &egui_kittest::Harness<'_, SlideApp>| { - h.output().platform_output.commands.iter().find_map(|c| match c { - egui::OutputCommand::CopyText(t) => Some(t.clone()), - _ => None, - }) - }; - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.state_mut().session.type_text("Aurora").unwrap(); - harness.state_mut().session.key("Escape", Mods::default()).unwrap(); - harness.run_steps(1); - assert_eq!(copied(&harness), None); - - harness.state_mut().run("edit.copy", json!({})).unwrap(); - harness.run_steps(1); - assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Copy puts the text on the clipboard"); - harness.run_steps(1); - assert_eq!(copied(&harness), None, "only once"); - - harness.state_mut().run("edit.cut", json!({})).unwrap(); - harness.run_steps(1); - assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Cut does too"); - assert!(harness.state().session.active().unwrap().doc.slides[0].shapes.iter().all(|s| s.id.0 != title), "and removes the shape"); - } - - /// #182: Ctrl+C then Ctrl+V did nothing for objects without text (a rectangle), because - /// egui-winit drops Ctrl+V when the system clipboard is empty; the context menu worked. - #[test] - fn copy_then_paste_shortcut_duplicates_an_object_without_text() { - 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); - harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); - let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len(); - let before = count(&harness); - - // What the copy leaves on the system clipboard is what egui-winit hands back on Ctrl+V. - assert_eq!(system_clipboard_text(""), OBJECTS_MARKER); - assert_eq!(system_clipboard_text("hello"), "hello"); - harness.event(egui::Event::Copy); - harness.run_steps(2); - harness.event(egui::Event::Paste(OBJECTS_MARKER.to_string())); - harness.run_steps(2); - assert_eq!(count(&harness), before + 1, "the shortcut pastes a copy of the rectangle"); - let d = harness.state().session.active().unwrap(); - let pasted = d.doc.slides[0].shapes.last().unwrap(); - assert!(pasted.text.as_ref().is_none_or(|t| t.text().is_empty()), "a copy of the rectangle, not a text box holding the marker"); - } - - /// #188: Ctrl/Cmd+A with the slide thumbnail pane focused selects every slide; with the - /// canvas focused it still selects shapes. - #[test] - fn select_all_follows_the_focused_pane() { - 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); - for _ in 0..2 { - harness.state_mut().run("slide.new", json!({})).unwrap(); - } - harness.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - harness.run_steps(3); - let click = |h: &mut egui_kittest::Harness<'_, SlideApp>, pos: egui::Pos2| { - h.event(egui::Event::PointerMoved(pos)); - for pressed in [true, false] { - h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - } - h.run_steps(2); - }; - // Press in the thumbnail pane, then Cmd+A. - let w = harness.state().ui.thumbs_width; - click(&mut harness, egui::pos2(w / 2.0, 300.0)); - assert!(harness.state().slides_focused); - harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - harness.run_steps(2); - let st = harness.state().session.active().unwrap(); - assert_eq!(st.selection.slides.len(), 3, "every slide is selected"); - - // A press on the canvas moves focus away: Cmd+A selects shapes again. - click(&mut harness, egui::pos2(700.0, 400.0)); - assert!(!harness.state().slides_focused); - harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - harness.run_steps(2); - let st = harness.state().session.active().unwrap(); - assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); - } - - fn harness() -> egui_kittest::Harness<'static, SlideApp> { - 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::new(), Services::default()), - ) - } - - /// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws - /// the open error right there (the status bar is hidden while the backstage is open, so the - /// message used to vanish). - #[test] - fn missing_recent_is_marked_and_its_error_shows() { - let mut harness = harness(); - let missing = "/deckcraft-tests/172/missing.deckcraft"; - harness.state_mut().ui.recent = vec![missing.to_string()]; - harness.run_steps(2); - // The row is labelled with the file's name. Clicking it raises the open error, and the - // drawn message is a second node that mentions the name (the OS text after it varies). - harness.get_by_label("missing.deckcraft").click(); - harness.run_steps(2); - // The file is read in the background (#350): wait for that read to finish. - for _ in 0..500 { - if harness.state().opening.is_none() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - harness.run_steps(1); - } - harness.run_steps(2); - assert!(harness.state().toast.is_some(), "the open error was raised"); - assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); - } - - /// #172: a Recent entry can be removed from the list. - #[test] - fn recent_entry_can_be_removed() { - let mut harness = harness(); - harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; - harness.run_steps(2); - let removes = harness.get_all_by_label("Remove from Recent").collect::>(); - assert_eq!(removes.len(), 2); - // The remove button must sit inside the visible part of the row even when the page's - // content is wider than the window. - assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); - removes[1].click(); - harness.run_steps(1); - assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); - } - - /// The existence probe tells present files from missing ones and never touches remote paths. - #[test] - fn recent_exists_tells_missing_from_present() { - let mut app = SlideApp::new(Session::new(), Services::default()); - let mut path = std::env::temp_dir(); - path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); - std::fs::write(&path, b"").unwrap(); - assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); - assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); - assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); - std::fs::remove_file(&path).ok(); - } - - /// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. - #[test] - fn the_language_dialog_marks_the_selected_text() { - use egui_kittest::kittest::Queryable; - 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 title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); - harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); - harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); - // The anchored window settles over its first frames; click once it stays put. - harness.run_steps(4); - harness.get_by_label("Français (France)").click(); - harness.run_steps(2); - harness.get_by_label("OK").click(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - let d = harness.state().session.active().unwrap(); - let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); - assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); - } - - /// Review > Language > Whole presentation marks every text box, not only the selected one. - #[test] - fn the_language_dialog_marks_the_whole_presentation() { - use egui_kittest::kittest::Queryable; - 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 s = &mut harness.state_mut().session; - let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; - s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); - s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); - s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap(); - s.execute("text.selectAll", &json!({})).unwrap(); - harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); - harness.run_steps(4); - harness.get_by_label("Whole presentation").click(); - harness.run_steps(2); - harness.get_by_label("Español (España)").click(); - harness.run_steps(2); - harness.get_by_label("OK").click(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - let d = harness.state().session.active().unwrap(); - for sl in &d.doc.slides { - let body = sl.shapes[0].text.as_ref().unwrap(); - assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); - } - } - - /// With nothing selected, Review > Language applies to the whole presentation without - /// choosing it, and Selected text can't be picked. - #[test] - fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() { - use egui_kittest::kittest::{NodeT, Queryable}; - 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 s = &mut harness.state_mut().session; - let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; - s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); - s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); - s.execute("edit.select", &json!({"ids": []})).unwrap(); - assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty()); - harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); - harness.run_steps(4); - assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled()); - harness.get_by_label("Español (España)").click(); - harness.run_steps(2); - harness.get_by_label("OK").click(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - let d = harness.state().session.active().unwrap(); - for sl in &d.doc.slides { - let body = sl.shapes[0].text.as_ref().unwrap(); - assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); - } - } - - /// Speaker notes keep their formatting when edited in the notes pane: typing into a bold - /// heading, Enter in a bulleted point, then saving as .pptx and reopening. - #[test] - fn editing_notes_in_the_pane_keeps_their_formatting() { - use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; - let point = |runs: Vec| Paragraph { - level: 1, - props: ParaProps { - margin_left: Some(22.5), - indent: Some(-22.5), - bullet: Some(Bullet::Char { char: "§".into() }), - bullet_font: Some("Wingdings".into()), - ..Default::default() - }, - runs, - ..Default::default() - }; - let bold = RunProps { bold: Some(true), ..Default::default() }; - let notes = TextBody { - paragraphs: vec![ - Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, - Paragraph::new("Žiaci pracujú vo dvojiciach."), - point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), - point(vec![Run::new("Reflexia")]), - Paragraph::new("Pomôcky: počítač"), - ], - ..Default::default() - }; - let mut session = Session::with_new(); - session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); - 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, Services::default()), - ); - harness.run_steps(2); - let id = egui::Id::unique("notes_edit"); - let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { - let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); - egui::TextEdit::store_state(&h.ctx, id, st); - }; - let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); - let text = notes.text(); - harness.ctx.memory_mut(|m| m.request_focus(id)); - harness.run_steps(1); - // Type into the bold heading. - caret(&mut harness, chars(&text, "prehľad")); - harness.run_steps(1); - harness.event(egui::Event::Text(" hodiny".into())); - harness.run_steps(1); - // Enter after a bulleted point, and type the next one. - let text = text.replacen("prehľad", "prehľad hodiny", 1); - caret(&mut harness, chars(&text, "cyklus")); - harness.run_steps(1); - harness.key_press(egui::Key::Enter); - harness.event(egui::Event::Text("Nový bod".into())); - harness.run_steps(1); - // Leave the field: the notes are saved. - harness.ctx.memory_mut(|m| m.surrender_focus(id)); - harness.run_steps(2); - let check = |n: &TextBody| { - assert_eq!( - n.text(), - "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" - ); - assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); - assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); - assert_eq!(n.paragraphs[1].props, ParaProps::default()); - assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); - assert_eq!(n.paragraphs[2].runs[1].props.bold, None); - for i in [2, 3, 4] { - assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); - } - assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); - }; - check(&harness.state().session.active().unwrap().doc.slides[0].notes); - // Saved as .pptx and reopened. - let s = &mut harness.state_mut().session; - let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); - s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); - check(&s.active().unwrap().doc.slides[0].notes); - } - - fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { - let mut h = 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), - ); - h.run_steps(2); - h - } - - /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. - #[test] - fn copy_offers_html_and_paste_keeps_bold() { - use std::cell::RefCell; - use std::rc::Rc; - let seen = Rc::new(RefCell::new(None)); - let record = seen.clone(); - let services = Services { - clipboard_set_html: Some(Box::new(move |html, plain| { - *record.borrow_mut() = Some((html.to_string(), plain.to_string())); - true - })), - ..Default::default() - }; - let mut h = step_app(services); - let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - h.event(egui::Event::Text("Hi".into())); - h.run_steps(1); - h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); - h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); - h.event(egui::Event::Copy); - h.run_steps(1); - let (html, plain) = seen.borrow().clone().expect("html offered"); - assert!(html.contains(""), "{html}"); - assert_eq!(plain, "Hi"); - assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); - - let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; - let mut h = step_app(services); - h.event(egui::Event::Paste("Hi".into())); - h.run_steps(1); - let st = h.state().session.active().unwrap(); - let body = st - .current_slide() - .unwrap() - .shapes - .iter() - .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) - .and_then(|sh| sh.text.clone()) - .expect("pasted text"); - assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); - } - - /// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can - /// still paste it here (#182). - #[test] - fn a_textless_shape_copies_as_the_objects_marker() { - let mut h = step_app(Services::default()); - h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); - h.event(egui::Event::Copy); - h.run_steps(1); - let cmds = &h.output().platform_output.commands; - assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); - } - - fn redder(px: [u8; 4]) -> i32 { - px[0] as i32 - px[1].max(px[2]) as i32 - } - - /// A fade entrance is hidden at the start of the preview clock and opaque once it has played. - #[test] - fn preview_hides_an_entrance_until_it_plays() { - let mut session = Session::with_new(); - let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); - session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); - session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); - session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); - let doc = session.active().unwrap().doc.clone(); - let tl = deckcraft_anim::ShowState::timeline(&doc, 0); - let shape = deckcraft_model::ShapeId(text as u32); - let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); - assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); - let mid = para(0.25, 0); - assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); - assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); - - session.execute("animation.clear", &json!({})).unwrap(); - let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); - session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); - session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); - let doc = session.active().unwrap().doc.clone(); - let tl = deckcraft_anim::ShowState::timeline(&doc, 0); - let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); - let paint = |time: Option| { - let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); - let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; - let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; - deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) - }; - let rest = paint(None); - let start = paint(Some(0.0)); - let end = paint(Some(tl.total_duration() + 0.05)); - assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); - assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); - assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); - } - - #[test] - fn animation_preview_plays_on_the_canvas_then_stops() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).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 none = harness.state_mut().run("animation.preview", json!({})).unwrap(); - assert_eq!(none["playing"], false); - assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); - assert!(harness.state().show.is_none()); - - let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] - .as_u64() - .unwrap(); - harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); - let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); - assert_eq!(started["playing"], true); - assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); - harness.run_steps(1); - let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); - assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); - let renders = harness.state().textures.canvas_renders; - harness.run_steps(6); - let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); - assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); - assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); - assert!(harness.state().show.is_none()); - - harness.key_press(egui::Key::Escape); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); - - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_some()); - harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); - assert!(harness.state().anim_preview.is_none()); - - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - let centre = harness.state().slide_rect.unwrap().center(); - harness.hover_at(centre); - harness.run_steps(1); - harness.drag_at(centre); - harness.run_steps(2); - assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); - - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); - - harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); - harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_some()); - // 400 ms effect plus the hold. - harness.run_steps(30); - assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); - assert!(harness.state().show.is_none()); - } - - fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.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); - // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. - harness.state_mut().ui.zoom = Some(0.4); - harness - } - - fn slide_at(app: &SlideApp) -> usize { - app.session.active().unwrap().selection.slide - } - - /// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a - /// mouse notch. The harness runs one frame per queued event, so they have to share a frame or - /// none of them reaches the page threshold. - fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { - let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); - let input = harness.input_mut(); - input.events.push(egui::Event::PointerMoved(centre)); - let step = delta / 4.0; - for _ in 0..4 { - input.events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: step, - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Trackpad, - modifiers: egui::Modifiers::NONE, - }); - } - harness.run_steps(1); - } - - #[test] - fn page_flick_uses_the_stronger_axis() { - assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); - assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); - assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); - assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); - } - - /// Test helper: place the pointer over the canvas centre, inject wheel notches, and step once. - fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { - let centre = harness.state().canvas_rect.unwrap().center(); - let input = harness.input_mut(); - input.events.push(egui::Event::PointerMoved(centre)); - for &y in notches { - input.events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, y), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers: egui::Modifiers::NONE, - }); - } - harness.run_steps(1); - } - - #[test] - fn mouse_wheel_keeps_fast_notches_in_both_directions_without_a_smoothed_tail() { - let mut h = flick_harness(); - for _ in 0..8 { - h.state_mut().run("slide.new", json!({})).unwrap(); - } - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.run_steps(1); - - mouse_wheel(&mut h, &[-1.0]); - assert_eq!(slide_at(h.state()), 1, "one notch responds immediately"); - mouse_wheel(&mut h, &[-1.0]); - assert_eq!(slide_at(h.state()), 2, "the next frame does not lose a notch to the cooldown"); - mouse_wheel(&mut h, &[-1.0, -1.0]); - assert_eq!(slide_at(h.state()), 4, "notches batched into a frame are retained"); - mouse_wheel(&mut h, &[-3.0]); - assert_eq!(slide_at(h.state()), 7, "an accelerated wheel event keeps its steps"); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 7, "smoothing must not turn more pages"); - mouse_wheel(&mut h, &[2.0]); - assert_eq!(slide_at(h.state()), 5, "reversing direction responds immediately"); - mouse_wheel(&mut h, &[100.0]); - assert_eq!(slide_at(h.state()), 0, "stop at the first slide"); - mouse_wheel(&mut h, &[-100.0]); - assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); - } - - /// A high-resolution wheel on Windows sends one notch as eight 0.125-line events. - #[test] - fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() { - let mut h = flick_harness(); - for _ in 0..4 { - h.state_mut().run("slide.new", json!({})).unwrap(); - } - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.run_steps(1); - - for _ in 0..7 { - mouse_wheel(&mut h, &[-0.125]); - assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page"); - } - mouse_wheel(&mut h, &[-0.125]); - assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide"); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page"); - - mouse_wheel(&mut h, &[-0.5]); - mouse_wheel(&mut h, &[0.5, 0.25]); - assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction"); - mouse_wheel(&mut h, &[0.25]); - assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); - } - - /// A fraction of a line left over in one presentation does not carry into another. - #[test] - fn mouse_wheel_fraction_does_not_carry_into_another_presentation() { - let mut h = flick_harness(); - h.state_mut().run("slide.new", json!({})).unwrap(); - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.run_steps(1); - mouse_wheel(&mut h, &[-0.75]); - assert_eq!(slide_at(h.state()), 0); - - let a = h.state().session.active_index().unwrap(); - h.state_mut().run("file.new", json!({})).unwrap(); - h.state_mut().run("slide.new", json!({})).unwrap(); - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.state_mut().ui.zoom = Some(0.4); - h.run_steps(2); - mouse_wheel(&mut h, &[-0.25]); - assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page"); - mouse_wheel(&mut h, &[-0.75]); - assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does"); - - // Back in the first one, leave 0.75 of a line, then switch away and back with the pointer - // off the canvas (on the tabs): the canvas never sees a hover in the other presentation. - let b = h.state().session.active_index().unwrap(); - h.state_mut().session.set_active(a); - h.state_mut().ui.zoom = Some(0.4); - h.run_steps(2); - mouse_wheel(&mut h, &[-0.75]); - assert_eq!(slide_at(h.state()), 0); - h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); - h.run_steps(1); - h.state_mut().session.set_active(b); - h.run_steps(2); - h.state_mut().session.set_active(a); - h.run_steps(2); - mouse_wheel(&mut h, &[-0.25]); - assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); - } - - /// Wheel input over a zoomed canvas pans it, while the same input outside the canvas must not - /// navigate slides. - #[test] - fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { - let mut h = flick_harness(); - h.state_mut().run("slide.new", json!({})).unwrap(); - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.state_mut().ui.zoom = Some(3.0); - h.run_steps(2); - let before = h.state().slide_rect.unwrap(); - mouse_wheel(&mut h, &[-3.0]); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 0); - assert!(h.state().slide_rect.unwrap().min.y < before.min.y, "the enlarged canvas scrolls"); - - h.state_mut().ui.zoom = Some(0.4); - h.run_steps(2); - h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); - h.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, -3.0), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers: egui::Modifiers::NONE, - }); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 0, "wheel input outside the canvas is ignored"); - } - - /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. - /// The frames of one gesture do not skip ahead. - #[test] - fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.run_steps(1); - assert_eq!(slide_at(harness.state()), 0); - - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); - - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); - - harness.run_steps(30); - flick(&mut harness, egui::vec2(24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); - } - - #[test] - fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.run_steps(1); - flick(&mut harness, egui::vec2(-2.0, -24.0)); - assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); - - harness.state_mut().ui.zoom = Some(3.0); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.run_steps(2); - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); - } - - /// `show.start {from}` that names no slide of the deck is refused, over `run` and over the - /// control channel, instead of starting at slide 0 or on the end screen. A hidden slide is a - /// slide: From Current Slide plays it. - #[test] - fn show_start_refuses_a_from_that_is_not_a_slide() { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - s.execute("slide.new", &json!({})).unwrap(); - s.execute("slide.hide", &json!({"index": 1, "hidden": true})).unwrap(); - let mut app = SlideApp::new(s, Services::default()); - let ctx = egui::Context::default(); - for from in [json!(-1), json!(1.5), json!("1"), json!(3), json!(u64::MAX)] { - assert!(app.run("show.start", json!({"from": from})).is_err(), "{from}"); - assert!(app.show.is_none(), "{from}"); - let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": from})); - let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!("{from}") }; - assert_eq!(answer["ok"], false, "{from}: {answer}"); - assert!(app.show.is_none(), "{from}"); - } - app.run("show.start", json!({"from": 1})).unwrap(); - assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.state.ended)), Some((1, false))); - app.show = None; - let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": 2, "reading": true})); - let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!() }; - assert_eq!(answer["ok"], true, "{answer}"); - assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.reading)), Some((2, true))); - } - - #[test] - fn a_swipe_during_the_show_turns_the_page() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.state_mut().start_show(0, true); - harness.run_steps(2); - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); - harness.run_steps(30); - flick(&mut harness, egui::vec2(24.0, 0.0)); - assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); - } - - /// The pen is drawing. A flick must not turn the page out from under the stroke. - #[test] - fn the_pen_ignores_a_swipe_during_the_show() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().start_show(0, true); - harness.run_steps(2); - harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); - harness.run_steps(1); - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); - } - - /// Preferences › Interface size (#767): dragging the slider leaves the interface size alone - /// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag. - #[test] - fn interface_size_applies_when_the_slider_is_let_go() { - 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); - harness.state_mut().run("app.preferences", json!({})).unwrap(); - harness.run_steps(4); - let before = harness.state().ui.ui_scale; - let rect = - harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect(); - let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0)); - harness.hover_at(from); - harness.run_steps(1); - harness.drag_at(from); - harness.run_steps(1); - for k in 1..=8 { - harness.hover_at(from.lerp(to, k as f32 / 8.0)); - harness.run_steps(1); - } - assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held"); - harness.drop_at(to); - harness.run_steps(2); - let after = harness.state().ui.ui_scale; - assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}"); - assert!(harness.state().dialog.is_some(), "Preferences stays open"); - } - /// The status bar's zoom slider has no room for a visible label; it still needs an accessible - /// name, and adding that name must not change the slider's shared control geometry at either - /// size. - #[test] - fn status_zoom_slider_is_named_without_growing_the_status_bar() { - for size in [egui::vec2(900.0, 560.0), egui::vec2(1440.0, 900.0)] { - let mut harness = egui_kittest::Harness::builder().with_size(size).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(3); - let mut zoom = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Zoom"); - let rect = zoom.next().unwrap_or_else(|| panic!("a named Zoom slider at {size:?}")).rect(); - assert!(zoom.next().is_none(), "exactly one Zoom slider"); - // egui 0.37's slider uses its own `slider_width` and the theme's control height - // (22 px), not the old hard-coded 110x18. Assert that real, shared geometry so the - // status row stays consistent with every other control instead of loosening a bound. - let style = harness.ctx.global_style(); - let (expected_w, expected_h) = (style.spacing.slider_width, style.spacing.interact_size.y); - assert!( - (rect.width() - expected_w).abs() <= 0.5 && (rect.height() - expected_h).abs() <= 0.5, - "the status-bar slider uses the shared {expected_w}x{expected_h} control size at {size:?}: {rect:?}" - ); - } - } - - /// Accessibility names survive the port: the Preferences dialog and the Format Shape fill page - /// expose no unnamed input widgets, and the fill Transparency row has one named slider rather - /// than a visible label duplicated by `Slider::text`. - #[test] - fn preferences_and_format_pane_name_every_input() { - 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(3); - harness.state_mut().run("app.preferences", json!({})).unwrap(); - harness.run_steps(3); - harness.check_accessibility(); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Preferences closed before the pane check"); - - harness.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); - harness.state_mut().ui.pane = Some("format".into()); - harness.state_mut().ui.format_tab = "fill".into(); - harness.run_steps(3); - harness.check_accessibility(); - let sliders: Vec<_> = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").collect(); - assert_eq!(sliders.len(), 1, "one fill Transparency slider"); - assert!(sliders[0].rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", sliders[0].rect()); - } - - /// A control `ui.screenshot` is admitted once, issued from a settled real UI pass, completed by - /// kittest's screenshot callback, and replied to exactly once after a logic drain. - #[test] - fn ui_screenshot_round_trips_through_the_callable_slot() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - let (tx, rx) = std::sync::mpsc::channel(); - harness.state_mut().control_rx = Some(rx); - let (req, reply) = ControlRequest::new("ui.screenshot", json!({})); - tx.send(req).unwrap(); - harness.run_steps(2); - assert_eq!(harness.state().capture.pending(), 1, "the request is admitted"); - harness.state_mut().capture.force_settle(); - harness.run_steps(4); - let reply = reply.recv_timeout(std::time::Duration::from_secs(5)).expect("screenshot reply"); - assert_eq!(reply["ok"], true, "{reply}"); - assert!(reply["result"]["width"].as_u64().is_some_and(|w| w > 0)); - assert_eq!(harness.state().capture.pending(), 0); - assert_eq!(harness.state().capture.reserved_bytes(), 0, "the reservation is released after the reply"); - } - - /// A hidden/logic-only iteration drains control requests and autosave/screenshot queues; it does - /// not need a painted frame for those side effects. - #[test] - fn logic_drains_control_without_a_ui_pass() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - let (req, reply) = ControlRequest::new("ui.inspect", json!({})); - tx.send(req).unwrap(); - app.logic(&ctx); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("reply from a logic-only call"); - assert_eq!(v["ok"], true, "{v}"); - } - - /// One synthetic pointer batch is injected at most once while it waits for a real UI pass. - /// eframe carries the whole `RawInput` from a logic-only frame into the next full pass, so a - /// document or layout change between them must retract the already-appended events too. - #[test] - fn synthetic_input_is_one_pending_batch_and_never_replays() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1, "the one pending event is injected once"); - assert!(app.synthetic.is_empty()); - // eframe's next logic-only frame hands the same RawInput back to the hook. The batch is - // still unacknowledged, so it must not be appended again. - let mut carried = raw.clone(); - app.raw_input_hook(&ctx, &mut carried); - assert_eq!(carried.events.len(), 1, "the unacknowledged batch is not injected twice"); - - // A settled UI pass acknowledges it; the next batch may then be injected. This test has - // no renderer, so clear the frame's texture uploads explicitly: egui 0.37 panics when a - // `TexturesDelta` with pending uploads is dropped. - app.fonts_ready = true; - app.styled = true; - ctx.run_ui(Default::default(), |ui| app.ui(ui)).drop_without_applying_deltas(); - - // Changing the document before the next pass drops a batch queued for the old one. - app.queue_input(egui::Event::PointerMoved(egui::pos2(20.0, 20.0))); - app.session.execute("file.new", &json!({})).unwrap(); - let mut raw2 = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw2); - assert!(raw2.events.is_empty(), "a batch queued for the old document is dropped"); - assert!(app.synthetic.is_empty()); - - // A pending batch for the current document is injected, then a document change before - // the next pass must retract it from the carried RawInput while keeping the newer click - // queued for the new document. - // The host supplies an identical pointer event before the injected batch: retraction must - // remove the batch at its recorded index, not the first identical window in the batch. - let moved = egui::Event::PointerMoved(egui::pos2(30.0, 30.0)); - app.queue_input(moved.clone()); - let mut raw3 = egui::RawInput::default(); - raw3.events.push(moved.clone()); - app.raw_input_hook(&ctx, &mut raw3); - assert_eq!(raw3.events, vec![moved.clone(), moved.clone()], "a settled pass permits the next batch"); - app.session.execute("file.new", &json!({})).unwrap(); - let newer = egui::Event::PointerMoved(egui::pos2(40.0, 40.0)); - app.queue_input(newer.clone()); - let host = egui::Event::Text("host".into()); - let mut carried3 = raw3.clone(); - carried3.events.push(host.clone()); - app.raw_input_hook(&ctx, &mut carried3); - assert_eq!( - carried3.events, - vec![moved, host, newer], - "the stale batch is retracted, host input and the newer click for the changed document are kept" - ); - assert!(app.synthetic.is_empty(), "the newer batch was injected, not cleared with the stale one"); - assert!(app.synthetic_pending, "the newer batch is now the one in flight"); - - let mut raw4 = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw4); - assert!(raw4.events.is_empty(), "the in-flight batch is not injected twice"); - } - - /// The first real pass installs the interface fonts and asks egui to discard it before any - /// layout. A control click that arrived in that same pass has already been consumed by egui, - /// so the app must put it back in the queue for the next frame instead of acknowledging and - /// losing it. - #[test] - fn a_control_click_on_the_first_font_discard_is_requeued_and_lands() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - let ctx = egui::Context::default(); - let pass = |app: &mut SlideApp, time: f64| { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut raw); - ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - }; - pass(&mut app, 1.0); - let size = app.session.active().unwrap().doc.slide_size; - app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); - app.session.execute("edit.deselect", &json!({})).unwrap(); - pass(&mut app, 2.0); - let point = app.slide_rect.expect("the canvas has been laid out").center(); - assert!(app.session.active().unwrap().selection.shapes.is_empty()); - - // Back to the first-frame state: the next pass installs fonts and is discarded. - app.styled = false; - app.fonts_ready = false; - app.queue_input(egui::Event::PointerMoved(point)); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::NONE, - }); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::NONE, - }); - pass(&mut app, 3.0); - assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the discarded pass did not run the canvas"); - assert!(!app.synthetic.is_empty(), "the click was requeued for the next frame"); - - // One pointer event is injected per pass: move, press, release. - for time in [4.0, 5.0, 6.0] { - pass(&mut app, time); - } - assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the requeued click selected the slide's shape"); - assert!(!app.synthetic_pending && app.synthetic.is_empty(), "the click was consumed exactly once"); - } - - /// A modal opening changes what a queued window-point event means even when the document and - /// canvas are unchanged; the injected batch is retracted before it reaches the modal. - #[test] - fn synthetic_batch_is_retracted_when_a_modal_opens_first() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1); - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "the click queued for the canvas does not reach the modal"); - assert!(!app.synthetic_pending); - } - - /// A control click is admitted for the full pass it was queued for. In that pass egui has - /// already derived `PointerState` from the injected press/release, so retracting only the raw - /// events would still let the click reach a document opened by the same pass's logic. The - /// control request therefore waits: the old target's real `logic` + `ui` pass consumes the - /// whole pointer gesture, and the next pass (no synthetic input) applies the document change. - #[test] - fn synthetic_click_is_consumed_on_its_old_target_before_logic_changes_the_document() { - // The destination has one shape over the whole slide, so a click that leaked into it - // would leave the shape selected. Opening it creates a fresh document state with an - // empty selection. - let mut destination = Session::with_new(); - let size = destination.active().unwrap().doc.slide_size; - destination.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); - let data = destination.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); - - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - let ctx = egui::Context::default(); - let pass = |app: &mut SlideApp, time: f64| { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut raw); - ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - }; - - pass(&mut app, 1.0); - app.run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap(); - assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the old document has a selection to clear"); - pass(&mut app, 2.0); - let old = app.session.active().unwrap().uid; - let slide = app.slide_rect.expect("the canvas has been laid out"); - // Well away from the inserted rectangle, so a press on the bare canvas clears the - // selection in the old document. - let point = egui::pos2(slide.min.x + slide.width() * 0.8, slide.min.y + slide.height() * 0.8); - app.queue_input(egui::Event::PointerMoved(point)); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::NONE, - }); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::NONE, - }); - - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - let (req, reply) = - ControlRequest::new("engine.execute", json!({"command": "file.openBytes", "params": {"name": "destination.deckcraft", "data": data}})); - tx.send(req).unwrap(); - - // `raw_input_hook` admits one pointer event per pass. Every one of those passes must stay - // on the old target; the control request may not create the destination before ui sees - // the press/release. - for time in [3.0, 4.0, 5.0] { - pass(&mut app, time); - assert_eq!(app.session.active().unwrap().uid, old, "the click's pass stays on its captured target"); - assert_eq!(app.session.documents().len(), 1, "logic must not open the destination under the click"); - } - assert!(!app.synthetic_pending, "the full passes consumed the gesture"); - assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the press selected the bare canvas on the old document"); - - // With no synthetic input pending, the next logic applies the deferred request. The - // destination's full-slide shape is unselected because no pointer state from the old - // pass was replayed into it. - pass(&mut app, 6.0); - let reply = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the click"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.session.documents().len(), 2); - let active = app.session.active().unwrap(); - assert_ne!(active.uid, old, "the destination opened only after the click was consumed"); - assert!(!active.doc.slides[0].shapes.is_empty(), "the destination really has a clickable shape"); - assert!(active.selection.shapes.is_empty(), "the old pass's click did not select the destination shape"); - } - - /// A queued control click belongs to the full-screen show, opening window and palette - /// selection that were on screen when it was queued; each transition is its own target. - #[test] - fn input_target_tracks_slideshow_opening_and_palette_selection() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let base = app.input_target(); - app.start_show(0, true); - assert_ne!(app.input_target(), base, "a slideshow is a different input target"); - let started = app.input_target(); - app.show.as_mut().unwrap().state.step += 1; - let stepped = app.input_target(); - assert_ne!(stepped, started, "the show step changes which click areas are live"); - let reading_now = app.show.as_ref().unwrap().reading; - app.show.as_mut().unwrap().reading = !reading_now; - let reading = app.input_target(); - assert_ne!(reading, stepped, "the reading layout moves the slide"); - app.show.as_mut().unwrap().grid = true; - let grid = app.input_target(); - assert_ne!(grid, reading, "See All Slides turns a click into a thumbnail choice"); - app.show.as_mut().unwrap().ended = true; - let ended = app.input_target(); - assert_ne!(ended, grid, "the end screen has different click targets"); - app.show = None; - assert_eq!(app.input_target(), base, "leaving the show returns to the editor target"); - - // Starting the show again is a new instance even at the same slide, so an old batch cannot - // match by value after an ABA transition. - app.start_show(0, true); - let first_instance = app.input_target(); - app.start_show(0, true); - assert_ne!(app.input_target(), first_instance, "a new show instance is a new target"); - app.show = None; - - app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); - assert_ne!(app.input_target(), base, "the background-open window is a different target"); - let opening = app.input_target(); - app.opening.as_mut().unwrap().generation = 2; - assert_ne!(app.input_target(), opening, "reopening the same path is a new attempt"); - app.opening = None; - - app.palette = Some(("open".into(), 0)); - let first = app.input_target(); - app.palette = Some(("open".into(), 1)); - assert_ne!(app.input_target(), first, "the palette's highlighted entry is part of the target"); - app.palette = None; - - app.ui.tab = "Insert".into(); - assert_ne!(app.input_target(), base, "another ribbon tab is another click target"); - let insert = app.input_target(); - app.ui.ribbon_collapsed = true; - assert_ne!(app.input_target(), insert, "collapsing the ribbon changes the layout under a click"); - app.ui.ribbon_collapsed = false; - app.ribbon_scroll.insert("Insert".into(), 64.0_f32.to_bits()); - assert_ne!(app.input_target(), insert, "scrolling the ribbon moves the buttons under a click"); - app.ui.tab = "Home".into(); - app.ribbon_scroll.remove("Insert"); - assert_eq!(app.input_target(), base); - } - - /// A click queued for the editor is retracted when a slideshow, background-open window or - /// different palette entry takes over before the input pass. - #[test] - fn input_target_changes_retract_a_queued_click() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let pointer = || egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); - - app.queue_input(pointer()); - app.start_show(0, true); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "a click queued for the editor does not enter the slideshow"); - assert!(app.synthetic.is_empty()); - app.show = None; - - app.queue_input(pointer()); - app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "a click queued for the editor does not enter the opening window"); - app.opening = None; - - app.palette = Some(("open".into(), 0)); - app.queue_input(pointer()); - app.palette = Some(("open".into(), 1)); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "a click queued for one palette selection does not activate another"); - assert!(app.synthetic.is_empty()); - } - - /// A logic-only callback does not consume events, but eframe carries the whole RawInput into - /// the next full pass. The hook injects without guessing the phase; when a control request - /// drained by that logic-only callback changes the document, the carried batch is retracted at - /// the next hook instead of being delivered into the new document. - #[test] - fn hidden_logic_pass_carries_a_synthetic_batch_and_drops_it_when_the_document_changes() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut hidden = egui::RawInput::default(); - hidden.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut hidden); - assert_eq!(hidden.events.len(), 1, "the batch is injected into the RawInput eframe will carry"); - assert!(app.synthetic.is_empty()); - assert!(app.synthetic_pending, "run_logic leaves the batch unacknowledged"); - - let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); - tx.send(req).unwrap(); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the hidden pass"); - assert_eq!(v["ok"], true, "{v}"); - assert_eq!(app.session.documents().len(), 2, "the hidden pass opened another document"); - assert!(app.synthetic_pending, "a logic-only callback cannot retract the carried batch"); - - // eframe hands the hidden RawInput to the next visible pass, appending new host input. - let mut visible = hidden.clone(); - visible.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - let host = egui::Event::Text("host".into()); - visible.events.push(host.clone()); - app.raw_input_hook(&ctx, &mut visible); - assert_eq!(visible.events, vec![host.clone()], "the click queued for the old document is dropped"); - assert!(app.synthetic.is_empty()); - ctx.run_ui(visible, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - assert!(app.synthetic.is_empty(), "nothing replays after the visible pass"); - } - - /// eframe may run logic-only for a *visible* root when a redraw deadline is overdue. The - /// previous pass's InputState is still live, so retraction must not drain an identical event - /// from it; the batch belongs to the carried RawInput and is removed at the next hook. - #[test] - fn visible_logic_pass_does_not_retract_from_the_previous_input_state() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - let pointer = egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); - - // Live InputState from the previous full pass, with an identical event at the same index. - let mut previous = egui::RawInput { time: Some(1.0), ..Default::default() }; - previous.events.push(pointer.clone()); - ctx.run_ui(previous, |_ui| {}).drop_without_applying_deltas(); - - app.queue_input(pointer.clone()); - let mut raw = egui::RawInput { time: Some(2.0), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events, vec![pointer.clone()]); - - let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); - tx.send(req).unwrap(); - let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the visible logic pass"); - assert_eq!(v["ok"], true, "{v}"); - assert_eq!(app.session.documents().len(), 2); - assert!(app.synthetic_pending, "the carried batch is still pending"); - assert_eq!(ctx.input(|i| i.events.clone()), vec![pointer.clone()], "the previous pass's events are untouched"); - - let mut next = raw.clone(); - next.time = Some(3.0); - let host = egui::Event::Text("host".into()); - next.events.push(host.clone()); - app.raw_input_hook(&ctx, &mut next); - assert_eq!(next.events, vec![host.clone()], "the stale batch is removed from the carried RawInput"); - assert!(!app.synthetic_pending); - assert!(app.synthetic.is_empty()); - } - - /// A batch injected in a logic-only pass is carried into the full pass with a refreshed - /// `RawInput::time`. That full pass must consume it on the old target: a control request - /// drained by the same logic waits until the next pass instead of changing the document - /// after egui has already derived the carried pointer state. - #[test] - fn carried_batch_is_consumed_before_the_full_pass_applies_a_control_change() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut hidden = egui::RawInput { time: Some(1.0), ..Default::default() }; - hidden.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut hidden); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - assert!(app.synthetic_pending, "the logic-only pass keeps the carried batch"); - - // eframe refreshes the carried input's time before calling the hook for the full pass. - let mut full = hidden.clone(); - full.time = Some(2.0); - app.raw_input_hook(&ctx, &mut full); - assert!(app.synthetic_pending, "the carried batch is not injected twice"); - - let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); - tx.send(req).unwrap(); - ctx.run_ui(full, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - assert_eq!(app.session.documents().len(), 1, "the full pass's UI sees its captured document"); - assert!(!app.synthetic_pending, "the full pass consumed the carried batch"); - - // The next pass has no synthetic input, so logic applies the deferred request. - let mut next = egui::RawInput { time: Some(3.0), ..Default::default() }; - next.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut next); - ctx.run_ui(next, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the full pass"); - assert_eq!(v["ok"], true, "{v}"); - assert_eq!(app.session.documents().len(), 2, "the deferred control request ran on the next pass"); - } - - /// `visible() == false` does not prove a logic-only pass: a screenshot or - /// `RequestPaintWhileHidden` makes eframe run the full UI. The synthetic batch must be - /// delivered there, not postponed until the viewport is visible. - #[test] - fn hidden_full_pass_receives_synthetic_ui_input() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - app.queue_input(egui::Event::Key { - key: egui::Key::Escape, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }); - let ctx = egui::Context::default(); - let mut hidden = egui::RawInput { time: Some(4.0), ..Default::default() }; - hidden.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut hidden); - assert_eq!(hidden.events.len(), 1, "a forced hidden full pass still gets the event"); - assert!(app.synthetic_pending); - - ctx.run_ui(hidden, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - assert!(app.dialog.is_none(), "the synthetic Escape reached the UI in the hidden full pass"); - assert!(!app.synthetic_pending, "the full pass acknowledged the batch"); - assert!(app.synthetic.is_empty()); - } - - /// egui 0.37 carries a clipboard image as `Event::PasteImage`; it inserts once, without - /// asking the host clipboard service for the same image again. - #[test] - fn paste_image_event_inserts_one_picture() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.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 before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); - let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)); - harness.event(egui::Event::PasteImage(std::sync::Arc::new(image))); - harness.run_steps(2); - let after = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); - assert_eq!(after, before + 1, "one image is inserted"); - } - - /// A minimized/occluded show keeps playing from logic alone: the clock advances on wall - /// time, an After transition advances the slide without a draw, and a visible pass resumes - /// from the show state instead of inventing a paused-show policy. - #[test] - fn hidden_viewport_advances_show_and_presenter_clocks_from_logic() { - /// Nested helper in the hidden-show test: build root `RawInput` with the given time and - /// visibility flags. - fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - raw - } - - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 0, "after": 1000})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); - app.start_show(0, false); - let ctx = egui::Context::default(); - - // A real visible UI pass starts the show and its clocks. There is no renderer here, so - // each frame's texture uploads are cleared explicitly (egui 0.37 panics on drop). - ctx.run_ui(raw_at(10.0, false, false), |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let start = app.show.as_ref().expect("show started").clock_snapshot(); - assert_eq!(start.step_start, 10.0); - assert_eq!(start.show_start, 10.0); - - // Hidden logic starts at the last visible egui time, then advances on the monotonic - // instant. Two seconds later the slide's After: 1 s transition has advanced the show. - let t0 = web_time::Instant::now(); - app.show.as_mut().expect("show started").observe_visible_frame(10.0, t0); - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0)); - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(2))); - let hidden = app.show.as_ref().expect("hidden show"); - assert_eq!(hidden.state.slide, 1, "the After transition advances without a draw"); - assert!((hidden.clock_snapshot().slide_start - 12.0).abs() < 1e-9); - assert!((hidden.presenter_elapsed(12.0) - 2.0).abs() < 1e-9, "presenter time keeps running"); - - // A later hidden tick does not replay the transition or stack time. - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(3))); - assert_eq!(app.show.as_ref().unwrap().state.slide, 1); - assert!((app.show.as_ref().unwrap().clock_snapshot().slide_start - 12.0).abs() < 1e-9); - - // A visible pass re-anchors to egui's real time and keeps the state it reached hidden. - ctx.run_ui(raw_at(20.0, false, false), |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let resumed = app.show.as_ref().expect("show resumed"); - assert_eq!(resumed.state.slide, 1); - assert!((resumed.presenter_elapsed(20.0) - 10.0).abs() < 1e-9); - assert!((resumed.clock_snapshot().slide_start - 12.0).abs() < 1e-9); - } - - /// A visible window can receive an overdue logic-only pass before the next paint. That pass - /// still carries the previous full frame's `InputState::time`; treating it as a visible frame - /// re-anchors the hidden clock to the later monotonic instant and loses elapsed show time. - /// Re-anchoring belongs to `show::ui`, which only runs on a real visible pass. - #[test] - fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { - /// Nested helper in the overdue-logic test: build root `RawInput` with the given time and - /// visibility flags. - fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - raw - } - - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 0, "after": 2500})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); - app.start_show(0, false); - let ctx = egui::Context::default(); - - // The full visible pass is the only pass that anchors the show: egui time 10. - ctx.run_ui(raw_at(10.0, false, false), |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let visible_anchor = web_time::Instant::now(); - - // An overdue visible logic-only pass does not refresh InputState::time. It must not move - // the hidden anchor from the full visible frame to this later instant. - let _ = ctx.run_logic(&raw_at(12.0, false, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(2))); - assert_eq!(ctx.input(|i| i.time), 10.0, "run_logic retains the last full pass's egui time"); - assert_eq!(app.show.as_ref().unwrap().state.slide, 0); - - // Three seconds after the full visible frame is past the slide's 2.5 s After delay. If the - // logic-only pass had re-anchored at +2 s, this hidden tick would still be only +1 s and - // the slide would not advance. - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); - assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); - } -} +mod tests; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs new file mode 100644 index 000000000..9765fe858 --- /dev/null +++ b/crates/ui-egui/src/tests.rs @@ -0,0 +1,2373 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; +use egui_kittest::kittest::Queryable; + +fn shape_rot(app: &SlideApp) -> f64 { + let d = app.session.active().unwrap(); + let id = d.selection.shapes[0]; + d.shape(id).unwrap().xfrm.unwrap().rot +} + +/// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; +/// letting go is one undo step. Off the canvas it does nothing. +#[test] +fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { + 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 id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); + let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.run_steps(1); + let canvas = harness.state().canvas_rect.expect("canvas"); + let outside = egui::pos2(1.0, 1.0); + assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); + + harness.input_mut().events.push(egui::Event::PointerMoved(outside)); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); + + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); + harness.run_steps(1); + assert!(harness.state().session.active().unwrap().interaction.is_none()); + harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); +} + +/// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). +#[test] +fn web_export_all_slides_gives_one_file_per_slide() { + let mut doc = deckcraft_model::Presentation::default(); + if let Some(first) = doc.slides.first().cloned() { + doc.slides.push(first.clone()); + doc.slides.push(first); + } + assert_eq!(doc.slides.len(), 3); + let all = web_image_files(&doc, 1, true, "Deck", "png", false); + let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); + let one = web_image_files(&doc, 1, false, "Deck", "png", false); + assert_eq!(one.len(), 1); + assert_eq!(one[0].0, "Deck.png"); +} + +/// The web PNG download honours "Transparent background" (#784): the slide background is +/// left out, so the corner is transparent; a JPEG download stays opaque. +#[test] +fn web_png_export_can_leave_the_background_transparent() { + use std::cell::RefCell; + use std::rc::Rc; + let got: Rc)>>> = Rc::default(); + let log = got.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3]; + assert!(app.export_as("png", json!({}))); + assert!(app.export_as("png", json!({"transparentBackground": true}))); + assert!(app.export_as("jpeg", json!({"transparentBackground": true}))); + let got = got.borrow(); + assert_eq!(got.len(), 3); + assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default"); + assert_eq!(corner_alpha(&got[1].1), 0, "transparent background"); + assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); +} + +fn title_text(app: &SlideApp) -> String { + let d = app.session.active().unwrap(); + let sh = &d.doc.slides[0].shapes[0]; + sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() +} + +#[test] +fn quick_access_toolbar_adds_removes_resets_and_persists() { + 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 ids = |app: &SlideApp| app.ui.qat.iter().map(|q| q.id.clone()).collect::>(); + assert_eq!(ids(harness.state()), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); + let app = harness.state_mut(); + app.run("app.qat", json!({"add": "insert.table", "label": "Table", "icon": "table"})).unwrap(); + app.run("app.qat", json!({"add": "insert.table"})).unwrap(); + app.run("app.qat", json!({"add": "view.pane", "params": {"pane": "selection"}, "label": "Selection Pane"})).unwrap(); + assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); + app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); + assert_eq!(app.ui.qat[5].label, "Table"); + // Saved with the rest of the UI state and read back. + let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.qat, app.ui.qat); + harness.run_steps(2); + let app = harness.state_mut(); + app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); + assert_eq!(app.ui.qat.len(), 6); + app.run("app.qat", json!({"reset": true})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); + // An older settings file without the toolbar gets the defaults. + let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); + assert_eq!(old.qat.len(), 6); +} + +#[test] +fn opening_a_file_with_missing_fonts_shows_a_toast_once() { + use egui_kittest::kittest::Queryable; + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into()); + slide.shapes[0].text = Some(body); + let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap()); + 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); + assert!(harness.state().toast.is_none(), "a new presentation has nothing to say"); + harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap(); + harness.run_steps(2); + let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default(); + assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}"); + assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some()); + // Closed, it stays closed for that document. + harness.get_by_label("Close").click(); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); +} + +/// #115: Change Picture replaced nothing — it inserted a second picture. +#[test] +fn change_picture_replaces_the_selected_picture() { + let svg = |c: &str| { + format!("").into_bytes() + }; + let media = |app: &SlideApp| { + let d = app.session.active().unwrap(); + let pics: Vec<_> = d.doc.slides[0] + .shapes + .iter() + .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) + .collect(); + assert_eq!(pics.len(), 1, "one picture, replaced in place"); + pics[0] + }; + let insert = |app: &mut SlideApp| { + let id = + app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() + ["id"] + .clone(); + app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + media(app) + }; + + // Desktop: the file dialog returns a path. + let blue = svg("blue"); + let services = Services { + pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), + read: Some(Box::new(move |_: &str| Ok(blue.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + assert_ne!(media(&app), before); + + // Web: the browser picker delivers the file through the inbox later. + let inbox = Inbox::default(); + let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), + inbox: Some(inbox.clone()), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + let id = started.lock().unwrap()[0]; + inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") })); + app.drain_inbox(); + assert_ne!(media(&app), before); +} + +/// #17: Home created a new blank presentation instead of going to the start screen. +#[test] +fn home_shows_the_start_screen_over_the_open_presentation() { + 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()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + + harness.state_mut().run("app.home", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().backstage_open()); + assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation"); + + // Typing on the start screen doesn't reach the presentation behind it; Escape goes back. + harness.event(egui::Event::Text("a".into())); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), ""); + assert!(!harness.state().session.active().unwrap().is_dirty()); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().backstage_open()); + + // Home again toggles; picking a theme on the start screen (a new presentation) leaves it. + harness.state_mut().run("app.home", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().backstage_open()); + harness.state_mut().run("app.home", json!({})).unwrap(); + assert!(!harness.state().backstage_open()); + harness.state_mut().run("app.home", json!({"on": true})).unwrap(); + harness.state_mut().run("file.new", json!({})).unwrap(); + harness.run_steps(2); + assert!(!harness.state().backstage_open(), "a new presentation leaves Home"); + assert_eq!(harness.state().session.documents().len(), 2); +} + +/// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real +/// egui key events (Windows keys). +#[test] +fn ctrl_word_keys_edit_text() { + use egui::{Key, Modifiers}; + let mut session = Session::with_new(); + session.mac_keys = false; + session.prefs.autocorrect = false; + 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, Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("Hello, world. Foo bar".into())); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Hello, world. Foo bar"); + let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap(); + let ctrl = Modifiers::COMMAND; + let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT; + + // Ctrl+Left stops at every word and punctuation run, as in PowerPoint. + let mut stops = vec![]; + for _ in 0..6 { + harness.key_press_modifiers(ctrl, Key::ArrowLeft); + harness.run_steps(1); + stops.push(caret(&harness).caret.1); + } + assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]); + harness.key_press_modifiers(ctrl, Key::ArrowRight); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (0, 5)); + + // Ctrl+Shift+Right selects to the next stops. + harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); + harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); + harness.run_steps(1); + let t = caret(&harness); + assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12))); + + // Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one + // before the caret. + harness.key_press_modifiers(ctrl, Key::Delete); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Helloworld. Foo bar"); + harness.key_press(Key::End); + harness.key_press_modifiers(ctrl, Key::Backspace); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Helloworld. Foo "); + + // Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends. + harness.key_press(Key::Enter); + harness.event(egui::Event::Text("Next".into())); + harness.key_press_modifiers(ctrl, Key::ArrowUp); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (1, 0)); + harness.key_press_modifiers(ctrl, Key::ArrowUp); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (0, 0)); + harness.key_press_modifiers(ctrl, Key::ArrowDown); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (1, 0)); + harness.key_press_modifiers(ctrl_shift, Key::Home); + harness.run_steps(1); + let t = caret(&harness); + assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0))); + + // Ctrl+Shift+> (egui reports the Period key) grows the selected text's font. + let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64(); + let before = size(&mut harness); + harness.key_press_modifiers(ctrl_shift, Key::Period); + harness.run_steps(1); + let after = size(&mut harness).unwrap(); + assert!(after > before.unwrap_or(0.0), "{before:?} → {after}"); + harness.key_press_modifiers(ctrl, Key::OpenBracket); + harness.run_steps(1); + assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks"); +} + +/// Export… had no visible control: only the command palette reached it. +#[test] +fn title_bar_export_opens_the_export_dialog() { + 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); + assert!(harness.state().dialog.is_none()); + // Quick access: Home, Save, Export, each 24 points wide with 2 between, from x = 10. + let pos = egui::pos2(10.0 + 2.0 * 26.0 + 12.0, ribbon::TITLE_H / 2.0); + harness.hover_at(pos); + harness.run_steps(1); + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); +} + +/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and +/// swallows the key press, so only the V release reaches the app. +#[test] +fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + assert!(!png.is_empty()); + let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; + 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, services), + ); + harness.run_steps(2); + let pictures = |app: &SlideApp| { + let d = app.session.active().unwrap(); + d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() + }; + let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // A text paste (Paste event on the press) does not also paste the image on release. + harness.event(egui::Event::Paste("hello".into())); + harness.run_steps(2); + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // A V release without Command/Ctrl is not a paste. + harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 1); +} + +/// #78: after Tab in a text box, typing and deleting no longer reached the text. +#[test] +fn typing_goes_on_after_tab_in_a_text_box() { + 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); + }, + { + let mut session = Session::with_new(); + session.prefs.autocorrect = false; + SlideApp::new(session, Services::default()) + }, + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("ab".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("c".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\tc"); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t"); + assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); + // Tab at the start of a paragraph indents it, and typing goes on there too (#93). + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("d".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t\nd"); + let level = |h: &egui_kittest::Harness| { + h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level + }; + assert_eq!(level(&harness), 1); +} + +/// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus +/// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the +/// list could neither be typed into nor continued with Enter. +#[test] +fn tab_in_an_edited_list_keeps_the_keyboard() { + 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 title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("one".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); // Increase List Level + harness.run_steps(2); + harness.event(egui::Event::Text("sub".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); // continues the list + harness.run_steps(2); + harness.event(egui::Event::Text("two".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text"); + let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap(); + let levels: Vec = body.paragraphs.iter().map(|p| p.level).collect(); + assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level"); +} + +#[test] +fn home_needs_an_open_presentation() { + let mut app = SlideApp::new(Session::new(), Services::default()); + app.run("app.home", json!({})).unwrap(); + assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); +} + +/// View › New Window is a second window on the open presentation. Its slide is its own; +/// the editor's selection stays where it was. Closing the window leaves the document open. +#[test] +fn new_window_shows_the_same_document_on_its_own_slide() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.go", &json!({"index": 1})).unwrap(); + let err = { + let mut empty = SlideApp::new(Session::new(), Services::default()); + empty.run("view.newWindow", json!({})).unwrap_err() + }; + assert!(err.contains("presentation"), "{err}"); + + let name = app.session.active().unwrap().title(); + let opened = app.run("view.newWindow", json!({})).unwrap(); + assert_eq!(opened["open"], true); + assert_eq!(opened["window"], 2); + assert_eq!(opened["slide"], 1); + assert_eq!(opened["title"], format!("{name}:2")); + assert_eq!(app.session.active().unwrap().selection.slide, 1); + + let moved = app.run("view.newWindow", json!({"delta": -1})).unwrap(); + assert_eq!(moved["slide"], 0); + assert_eq!(app.doc_window.as_ref().unwrap().slide, 0); + assert_eq!(app.session.active().unwrap().selection.slide, 1, "the editor stays on its slide"); + + let again = app.run("view.newWindow", json!({})).unwrap(); + assert_eq!(again["window"], 2); + assert_eq!(again["slide"], 0, "opening again keeps the window's slide"); + + app.session.execute("file.close", &json!({})).unwrap(); + doc_window::show(&mut app, &egui::Context::default()); + assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); + assert!(app.session.active().is_none()); +} + +#[test] +fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); +} + +#[test] +fn grid_options_dialog_lists_spacing_and_display() { + use egui_kittest::kittest::Queryable; + 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); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); +} + +/// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog +/// changed while it was open; Done keeps it. +#[test] +fn preferences_cancel_restores_the_settings_done_keeps_them() { + use egui_kittest::kittest::Queryable; + 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 before = harness.state().session.prefs.clone(); + let dark_before = harness.state().ui.brightness; + let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| { + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(2); + for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] { + harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label) + .next() + .unwrap_or_else(|| panic!("no {label} checkbox")) + .click_accesskit(); + harness.run_steps(2); + } + assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance"); + assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides); + }; + let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| { + let s = harness.state(); + assert!(s.dialog.is_none(), "{how} closes Preferences"); + assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance"); + assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides"); + assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid"); + }; + + change(&mut harness); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + unchanged(&harness, "Cancel"); + + change(&mut harness); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + unchanged(&harness, "Esc"); + + change(&mut harness); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Done") + .next() + .unwrap_or_else(|| panic!("no Done button")) + .click_accesskit(); + harness.run_steps(2); + let s = harness.state(); + assert!(s.dialog.is_none(), "Done closes Preferences"); + assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance"); + assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides"); + assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid"); +} + +/// About dialog: Close and Cancel each close the dialog. +#[test] +fn about_dialog_close_and_cancel_close_it() { + use egui_kittest::kittest::Queryable; + 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); + harness.state_mut().run("app.about", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button in About dialog")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes About dialog"); + + harness.state_mut().run("app.about", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Close") + .next() + .unwrap_or_else(|| panic!("no Close button in About dialog")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Close closes About dialog"); +} + +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 _gpu = crate::gpu_lock::gpu_lock(); + 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"); +} + +/// #431: the ribbon's Copy and Cut, the slide's context menu, the menus and the control +/// channel all run the commands; they put the text on the system clipboard, as Ctrl+C and +/// Ctrl+X do. +#[test] +fn copy_and_cut_commands_reach_the_system_clipboard() { + 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 copied = |h: &egui_kittest::Harness<'_, SlideApp>| { + h.output().platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + }; + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.type_text("Aurora").unwrap(); + harness.state_mut().session.key("Escape", Mods::default()).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness), None); + + harness.state_mut().run("edit.copy", json!({})).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Copy puts the text on the clipboard"); + harness.run_steps(1); + assert_eq!(copied(&harness), None, "only once"); + + harness.state_mut().run("edit.cut", json!({})).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Cut does too"); + assert!(harness.state().session.active().unwrap().doc.slides[0].shapes.iter().all(|s| s.id.0 != title), "and removes the shape"); +} + +/// #182: Ctrl+C then Ctrl+V did nothing for objects without text (a rectangle), because +/// egui-winit drops Ctrl+V when the system clipboard is empty; the context menu worked. +#[test] +fn copy_then_paste_shortcut_duplicates_an_object_without_text() { + 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); + harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); + let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len(); + let before = count(&harness); + + // What the copy leaves on the system clipboard is what egui-winit hands back on Ctrl+V. + assert_eq!(system_clipboard_text(""), OBJECTS_MARKER); + assert_eq!(system_clipboard_text("hello"), "hello"); + harness.event(egui::Event::Copy); + harness.run_steps(2); + harness.event(egui::Event::Paste(OBJECTS_MARKER.to_string())); + harness.run_steps(2); + assert_eq!(count(&harness), before + 1, "the shortcut pastes a copy of the rectangle"); + let d = harness.state().session.active().unwrap(); + let pasted = d.doc.slides[0].shapes.last().unwrap(); + assert!(pasted.text.as_ref().is_none_or(|t| t.text().is_empty()), "a copy of the rectangle, not a text box holding the marker"); +} + +/// #188: Ctrl/Cmd+A with the slide thumbnail pane focused selects every slide; with the +/// canvas focused it still selects shapes. +#[test] +fn select_all_follows_the_focused_pane() { + 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); + for _ in 0..2 { + harness.state_mut().run("slide.new", json!({})).unwrap(); + } + harness.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + harness.run_steps(3); + let click = |h: &mut egui_kittest::Harness<'_, SlideApp>, pos: egui::Pos2| { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); + }; + // Press in the thumbnail pane, then Cmd+A. + let w = harness.state().ui.thumbs_width; + click(&mut harness, egui::pos2(w / 2.0, 300.0)); + assert!(harness.state().slides_focused); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + assert_eq!(st.selection.slides.len(), 3, "every slide is selected"); + + // A press on the canvas moves focus away: Cmd+A selects shapes again. + click(&mut harness, egui::pos2(700.0, 400.0)); + assert!(!harness.state().slides_focused); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); +} + +fn harness() -> egui_kittest::Harness<'static, SlideApp> { + 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::new(), Services::default()), + ) +} + +/// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws +/// the open error right there (the status bar is hidden while the backstage is open, so the +/// message used to vanish). +#[test] +fn missing_recent_is_marked_and_its_error_shows() { + let mut harness = harness(); + let missing = "/deckcraft-tests/172/missing.deckcraft"; + harness.state_mut().ui.recent = vec![missing.to_string()]; + harness.run_steps(2); + // The row is labelled with the file's name. Clicking it raises the open error, and the + // drawn message is a second node that mentions the name (the OS text after it varies). + harness.get_by_label("missing.deckcraft").click(); + harness.run_steps(2); + // The file is read in the background (#350): wait for that read to finish. + for _ in 0..500 { + if harness.state().opening.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + harness.run_steps(1); + } + harness.run_steps(2); + assert!(harness.state().toast.is_some(), "the open error was raised"); + assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); +} + +/// #172: a Recent entry can be removed from the list. +#[test] +fn recent_entry_can_be_removed() { + let mut harness = harness(); + harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; + harness.run_steps(2); + let removes = harness.get_all_by_label("Remove from Recent").collect::>(); + assert_eq!(removes.len(), 2); + // The remove button must sit inside the visible part of the row even when the page's + // content is wider than the window. + assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); + removes[1].click(); + harness.run_steps(1); + assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); +} + +/// The existence probe tells present files from missing ones and never touches remote paths. +#[test] +fn recent_exists_tells_missing_from_present() { + let mut app = SlideApp::new(Session::new(), Services::default()); + let mut path = std::env::temp_dir(); + path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); + std::fs::write(&path, b"").unwrap(); + assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); + assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); + assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); + std::fs::remove_file(&path).ok(); +} + +/// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. +#[test] +fn the_language_dialog_marks_the_selected_text() { + use egui_kittest::kittest::Queryable; + 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 title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); + harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + // The anchored window settles over its first frames; click once it stays put. + harness.run_steps(4); + harness.get_by_label("Français (France)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); +} + +/// Review > Language > Whole presentation marks every text box, not only the selected one. +#[test] +fn the_language_dialog_marks_the_whole_presentation() { + use egui_kittest::kittest::Queryable; + 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 s = &mut harness.state_mut().session; + let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); + s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); + s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + harness.run_steps(4); + harness.get_by_label("Whole presentation").click(); + harness.run_steps(2); + harness.get_by_label("Español (España)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + for sl in &d.doc.slides { + let body = sl.shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); + } +} + +/// With nothing selected, Review > Language applies to the whole presentation without +/// choosing it, and Selected text can't be picked. +#[test] +fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() { + use egui_kittest::kittest::{NodeT, Queryable}; + 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 s = &mut harness.state_mut().session; + let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); + s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty()); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + harness.run_steps(4); + assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled()); + harness.get_by_label("Español (España)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + for sl in &d.doc.slides { + let body = sl.shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); + } +} + +/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold +/// heading, Enter in a bulleted point, then saving as .pptx and reopening. +#[test] +fn editing_notes_in_the_pane_keeps_their_formatting() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |runs: Vec| Paragraph { + level: 1, + props: ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(Bullet::Char { char: "§".into() }), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }, + runs, + ..Default::default() + }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), + point(vec![Run::new("Reflexia")]), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + }; + let mut session = Session::with_new(); + session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); + 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, Services::default()), + ); + harness.run_steps(2); + let id = egui::Id::unique("notes_edit"); + let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { + let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); + egui::TextEdit::store_state(&h.ctx, id, st); + }; + let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); + let text = notes.text(); + harness.ctx.memory_mut(|m| m.request_focus(id)); + harness.run_steps(1); + // Type into the bold heading. + caret(&mut harness, chars(&text, "prehľad")); + harness.run_steps(1); + harness.event(egui::Event::Text(" hodiny".into())); + harness.run_steps(1); + // Enter after a bulleted point, and type the next one. + let text = text.replacen("prehľad", "prehľad hodiny", 1); + caret(&mut harness, chars(&text, "cyklus")); + harness.run_steps(1); + harness.key_press(egui::Key::Enter); + harness.event(egui::Event::Text("Nový bod".into())); + harness.run_steps(1); + // Leave the field: the notes are saved. + harness.ctx.memory_mut(|m| m.surrender_focus(id)); + harness.run_steps(2); + let check = |n: &TextBody| { + assert_eq!( + n.text(), + "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" + ); + assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); + assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(n.paragraphs[1].props, ParaProps::default()); + assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); + assert_eq!(n.paragraphs[2].runs[1].props.bold, None); + for i in [2, 3, 4] { + assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); + } + assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); + }; + check(&harness.state().session.active().unwrap().doc.slides[0].notes); + // Saved as .pptx and reopened. + let s = &mut harness.state_mut().session; + let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); + check(&s.active().unwrap().doc.slides[0].notes); +} + +fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { + let mut h = 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), + ); + h.run_steps(2); + h +} + +/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. +#[test] +fn copy_offers_html_and_paste_keeps_bold() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(None)); + let record = seen.clone(); + let services = Services { + clipboard_set_html: Some(Box::new(move |html, plain| { + *record.borrow_mut() = Some((html.to_string(), plain.to_string())); + true + })), + ..Default::default() + }; + let mut h = step_app(services); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + h.event(egui::Event::Text("Hi".into())); + h.run_steps(1); + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let (html, plain) = seen.borrow().clone().expect("html offered"); + assert!(html.contains(""), "{html}"); + assert_eq!(plain, "Hi"); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); + + let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; + let mut h = step_app(services); + h.event(egui::Event::Paste("Hi".into())); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + let body = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .and_then(|sh| sh.text.clone()) + .expect("pasted text"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); +} + +/// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can +/// still paste it here (#182). +#[test] +fn a_textless_shape_copies_as_the_objects_marker() { + let mut h = step_app(Services::default()); + h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let cmds = &h.output().platform_output.commands; + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); +} + +fn redder(px: [u8; 4]) -> i32 { + px[0] as i32 - px[1].max(px[2]) as i32 +} + +/// A fade entrance is hidden at the start of the preview clock and opaque once it has played. +#[test] +fn preview_hides_an_entrance_until_it_plays() { + let mut session = Session::with_new(); + let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let shape = deckcraft_model::ShapeId(text as u32); + let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); + assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); + let mid = para(0.25, 0); + assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); + assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); + + session.execute("animation.clear", &json!({})).unwrap(); + let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); + let paint = |time: Option| { + let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); + let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; + let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; + deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) + }; + let rest = paint(None); + let start = paint(Some(0.0)); + let end = paint(Some(tl.total_duration() + 0.05)); + assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); + assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); + assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); +} + +#[test] +fn animation_preview_plays_on_the_canvas_then_stops() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).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 none = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(none["playing"], false); + assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); + assert!(harness.state().show.is_none()); + + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); + let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(started["playing"], true); + assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); + harness.run_steps(1); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); + let renders = harness.state().textures.canvas_renders; + harness.run_steps(6); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); + assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); + assert!(harness.state().show.is_none()); + + harness.key_press(egui::Key::Escape); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); + assert!(harness.state().anim_preview.is_none()); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + let centre = harness.state().slide_rect.unwrap().center(); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(2); + assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); + + harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + // 400 ms effect plus the hold. + harness.run_steps(30); + assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); + assert!(harness.state().show.is_none()); +} + +fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.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); + // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. + harness.state_mut().ui.zoom = Some(0.4); + harness +} + +fn slide_at(app: &SlideApp) -> usize { + app.session.active().unwrap().selection.slide +} + +/// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a +/// mouse notch. The harness runs one frame per queued event, so they have to share a frame or +/// none of them reaches the page threshold. +fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { + let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + let step = delta / 4.0; + for _ in 0..4 { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: step, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Trackpad, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); +} + +#[test] +fn page_flick_uses_the_stronger_axis() { + assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); + assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); +} + +/// Test helper: place the pointer over the canvas centre, inject wheel notches, and step once. +fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { + let centre = harness.state().canvas_rect.unwrap().center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + for &y in notches { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); +} + +#[test] +fn mouse_wheel_keeps_fast_notches_in_both_directions_without_a_smoothed_tail() { + let mut h = flick_harness(); + for _ in 0..8 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 1, "one notch responds immediately"); + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 2, "the next frame does not lose a notch to the cooldown"); + mouse_wheel(&mut h, &[-1.0, -1.0]); + assert_eq!(slide_at(h.state()), 4, "notches batched into a frame are retained"); + mouse_wheel(&mut h, &[-3.0]); + assert_eq!(slide_at(h.state()), 7, "an accelerated wheel event keeps its steps"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 7, "smoothing must not turn more pages"); + mouse_wheel(&mut h, &[2.0]); + assert_eq!(slide_at(h.state()), 5, "reversing direction responds immediately"); + mouse_wheel(&mut h, &[100.0]); + assert_eq!(slide_at(h.state()), 0, "stop at the first slide"); + mouse_wheel(&mut h, &[-100.0]); + assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); +} + +/// A high-resolution wheel on Windows sends one notch as eight 0.125-line events. +#[test] +fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() { + let mut h = flick_harness(); + for _ in 0..4 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + for _ in 0..7 { + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page"); + } + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page"); + + mouse_wheel(&mut h, &[-0.5]); + mouse_wheel(&mut h, &[0.5, 0.25]); + assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction"); + mouse_wheel(&mut h, &[0.25]); + assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); +} + +/// A fraction of a line left over in one presentation does not carry into another. +#[test] +fn mouse_wheel_fraction_does_not_carry_into_another_presentation() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 0); + + let a = h.state().session.active_index().unwrap(); + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.25]); + assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page"); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does"); + + // Back in the first one, leave 0.75 of a line, then switch away and back with the pointer + // off the canvas (on the tabs): the canvas never sees a hover in the other presentation. + let b = h.state().session.active_index().unwrap(); + h.state_mut().session.set_active(a); + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 0); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.run_steps(1); + h.state_mut().session.set_active(b); + h.run_steps(2); + h.state_mut().session.set_active(a); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.25]); + assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); +} + +/// Wheel input over a zoomed canvas pans it, while the same input outside the canvas must not +/// navigate slides. +#[test] +fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(3.0); + h.run_steps(2); + let before = h.state().slide_rect.unwrap(); + mouse_wheel(&mut h, &[-3.0]); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0); + assert!(h.state().slide_rect.unwrap().min.y < before.min.y, "the enlarged canvas scrolls"); + + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0, "wheel input outside the canvas is ignored"); +} + +/// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. +/// The frames of one gesture do not skip ahead. +#[test] +fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + assert_eq!(slide_at(harness.state()), 0); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); + + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); +} + +#[test] +fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-2.0, -24.0)); + assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); + + harness.state_mut().ui.zoom = Some(3.0); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); +} + +/// `show.start {from}` that names no slide of the deck is refused, over `run` and over the +/// control channel, instead of starting at slide 0 or on the end screen. A hidden slide is a +/// slide: From Current Slide plays it. +#[test] +fn show_start_refuses_a_from_that_is_not_a_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.hide", &json!({"index": 1, "hidden": true})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + let ctx = egui::Context::default(); + for from in [json!(-1), json!(1.5), json!("1"), json!(3), json!(u64::MAX)] { + assert!(app.run("show.start", json!({"from": from})).is_err(), "{from}"); + assert!(app.show.is_none(), "{from}"); + let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": from})); + let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!("{from}") }; + assert_eq!(answer["ok"], false, "{from}: {answer}"); + assert!(app.show.is_none(), "{from}"); + } + app.run("show.start", json!({"from": 1})).unwrap(); + assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.state.ended)), Some((1, false))); + app.show = None; + let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": 2, "reading": true})); + let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!() }; + assert_eq!(answer["ok"], true, "{answer}"); + assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.reading)), Some((2, true))); +} + +#[test] +fn a_swipe_during_the_show_turns_the_page() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); +} + +/// The pen is drawing. A flick must not turn the page out from under the stroke. +#[test] +fn the_pen_ignores_a_swipe_during_the_show() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); +} + +/// Preferences › Interface size (#767): dragging the slider leaves the interface size alone +/// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag. +#[test] +fn interface_size_applies_when_the_slider_is_let_go() { + 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); + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(4); + let before = harness.state().ui.ui_scale; + let rect = + harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect(); + let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0)); + harness.hover_at(from); + harness.run_steps(1); + harness.drag_at(from); + harness.run_steps(1); + for k in 1..=8 { + harness.hover_at(from.lerp(to, k as f32 / 8.0)); + harness.run_steps(1); + } + assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held"); + harness.drop_at(to); + harness.run_steps(2); + let after = harness.state().ui.ui_scale; + assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}"); + assert!(harness.state().dialog.is_some(), "Preferences stays open"); +} +/// The status bar's zoom slider has no room for a visible label; it still needs an accessible +/// name, and adding that name must not change the slider's shared control geometry at either +/// size. +#[test] +fn status_zoom_slider_is_named_without_growing_the_status_bar() { + for size in [egui::vec2(900.0, 560.0), egui::vec2(1440.0, 900.0)] { + let mut harness = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(3); + let mut zoom = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Zoom"); + let rect = zoom.next().unwrap_or_else(|| panic!("a named Zoom slider at {size:?}")).rect(); + assert!(zoom.next().is_none(), "exactly one Zoom slider"); + // egui 0.37's slider uses its own `slider_width` and the theme's control height + // (22 px), not the old hard-coded 110x18. Assert that real, shared geometry so the + // status row stays consistent with every other control instead of loosening a bound. + let style = harness.ctx.global_style(); + let (expected_w, expected_h) = (style.spacing.slider_width, style.spacing.interact_size.y); + assert!( + (rect.width() - expected_w).abs() <= 0.5 && (rect.height() - expected_h).abs() <= 0.5, + "the status-bar slider uses the shared {expected_w}x{expected_h} control size at {size:?}: {rect:?}" + ); + } +} + +/// Accessibility names survive the port: the Preferences dialog and the Format Shape fill page +/// expose no unnamed input widgets, and the fill Transparency row has one named slider rather +/// than a visible label duplicated by `Slider::text`. +#[test] +fn preferences_and_format_pane_name_every_input() { + 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(3); + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(3); + harness.check_accessibility(); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Preferences closed before the pane check"); + + harness.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + harness.state_mut().ui.pane = Some("format".into()); + harness.state_mut().ui.format_tab = "fill".into(); + harness.run_steps(3); + harness.check_accessibility(); + let sliders: Vec<_> = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").collect(); + assert_eq!(sliders.len(), 1, "one fill Transparency slider"); + assert!(sliders[0].rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", sliders[0].rect()); +} + +/// A control `ui.screenshot` is admitted once, issued from a settled real UI pass, completed by +/// kittest's screenshot callback, and replied to exactly once after a logic drain. +#[test] +fn ui_screenshot_round_trips_through_the_callable_slot() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + let (tx, rx) = std::sync::mpsc::channel(); + harness.state_mut().control_rx = Some(rx); + let (req, reply) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(req).unwrap(); + harness.run_steps(2); + assert_eq!(harness.state().capture.pending(), 1, "the request is admitted"); + harness.state_mut().capture.force_settle(); + harness.run_steps(4); + let reply = reply.recv_timeout(std::time::Duration::from_secs(5)).expect("screenshot reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(reply["result"]["width"].as_u64().is_some_and(|w| w > 0)); + assert_eq!(harness.state().capture.pending(), 0); + assert_eq!(harness.state().capture.reserved_bytes(), 0, "the reservation is released after the reply"); +} + +/// A hidden/logic-only iteration drains control requests and autosave/screenshot queues; it does +/// not need a painted frame for those side effects. +#[test] +fn logic_drains_control_without_a_ui_pass() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let (req, reply) = ControlRequest::new("ui.inspect", json!({})); + tx.send(req).unwrap(); + app.logic(&ctx); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("reply from a logic-only call"); + assert_eq!(v["ok"], true, "{v}"); +} + +/// One synthetic pointer batch is injected at most once while it waits for a real UI pass. +/// eframe carries the whole `RawInput` from a logic-only frame into the next full pass, so a +/// document or layout change between them must retract the already-appended events too. +#[test] +fn synthetic_input_is_one_pending_batch_and_never_replays() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the one pending event is injected once"); + assert!(app.synthetic.is_empty()); + // eframe's next logic-only frame hands the same RawInput back to the hook. The batch is + // still unacknowledged, so it must not be appended again. + let mut carried = raw.clone(); + app.raw_input_hook(&ctx, &mut carried); + assert_eq!(carried.events.len(), 1, "the unacknowledged batch is not injected twice"); + + // A settled UI pass acknowledges it; the next batch may then be injected. This test has + // no renderer, so clear the frame's texture uploads explicitly: egui 0.37 panics when a + // `TexturesDelta` with pending uploads is dropped. + app.fonts_ready = true; + app.styled = true; + ctx.run_ui(Default::default(), |ui| app.ui(ui)).drop_without_applying_deltas(); + + // Changing the document before the next pass drops a batch queued for the old one. + app.queue_input(egui::Event::PointerMoved(egui::pos2(20.0, 20.0))); + app.session.execute("file.new", &json!({})).unwrap(); + let mut raw2 = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw2); + assert!(raw2.events.is_empty(), "a batch queued for the old document is dropped"); + assert!(app.synthetic.is_empty()); + + // A pending batch for the current document is injected, then a document change before + // the next pass must retract it from the carried RawInput while keeping the newer click + // queued for the new document. + // The host supplies an identical pointer event before the injected batch: retraction must + // remove the batch at its recorded index, not the first identical window in the batch. + let moved = egui::Event::PointerMoved(egui::pos2(30.0, 30.0)); + app.queue_input(moved.clone()); + let mut raw3 = egui::RawInput::default(); + raw3.events.push(moved.clone()); + app.raw_input_hook(&ctx, &mut raw3); + assert_eq!(raw3.events, vec![moved.clone(), moved.clone()], "a settled pass permits the next batch"); + app.session.execute("file.new", &json!({})).unwrap(); + let newer = egui::Event::PointerMoved(egui::pos2(40.0, 40.0)); + app.queue_input(newer.clone()); + let host = egui::Event::Text("host".into()); + let mut carried3 = raw3.clone(); + carried3.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut carried3); + assert_eq!( + carried3.events, + vec![moved, host, newer], + "the stale batch is retracted, host input and the newer click for the changed document are kept" + ); + assert!(app.synthetic.is_empty(), "the newer batch was injected, not cleared with the stale one"); + assert!(app.synthetic_pending, "the newer batch is now the one in flight"); + + let mut raw4 = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw4); + assert!(raw4.events.is_empty(), "the in-flight batch is not injected twice"); +} + +/// The first real pass installs the interface fonts and asks egui to discard it before any +/// layout. A control click that arrived in that same pass has already been consumed by egui, +/// so the app must put it back in the queue for the next frame instead of acknowledging and +/// losing it. +#[test] +fn a_control_click_on_the_first_font_discard_is_requeued_and_lands() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let pass = |app: &mut SlideApp, time: f64| { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + }; + pass(&mut app, 1.0); + let size = app.session.active().unwrap().doc.slide_size; + app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + app.session.execute("edit.deselect", &json!({})).unwrap(); + pass(&mut app, 2.0); + let point = app.slide_rect.expect("the canvas has been laid out").center(); + assert!(app.session.active().unwrap().selection.shapes.is_empty()); + + // Back to the first-frame state: the next pass installs fonts and is discarded. + app.styled = false; + app.fonts_ready = false; + app.queue_input(egui::Event::PointerMoved(point)); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + pass(&mut app, 3.0); + assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the discarded pass did not run the canvas"); + assert!(!app.synthetic.is_empty(), "the click was requeued for the next frame"); + + // One pointer event is injected per pass: move, press, release. + for time in [4.0, 5.0, 6.0] { + pass(&mut app, time); + } + assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the requeued click selected the slide's shape"); + assert!(!app.synthetic_pending && app.synthetic.is_empty(), "the click was consumed exactly once"); +} + +/// A modal opening changes what a queued window-point event means even when the document and +/// canvas are unchanged; the injected batch is retracted before it reaches the modal. +#[test] +fn synthetic_batch_is_retracted_when_a_modal_opens_first() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "the click queued for the canvas does not reach the modal"); + assert!(!app.synthetic_pending); +} + +/// A control click is admitted for the full pass it was queued for. In that pass egui has +/// already derived `PointerState` from the injected press/release, so retracting only the raw +/// events would still let the click reach a document opened by the same pass's logic. The +/// control request therefore waits: the old target's real `logic` + `ui` pass consumes the +/// whole pointer gesture, and the next pass (no synthetic input) applies the document change. +#[test] +fn synthetic_click_is_consumed_on_its_old_target_before_logic_changes_the_document() { + // The destination has one shape over the whole slide, so a click that leaked into it + // would leave the shape selected. Opening it creates a fresh document state with an + // empty selection. + let mut destination = Session::with_new(); + let size = destination.active().unwrap().doc.slide_size; + destination.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + let data = destination.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let pass = |app: &mut SlideApp, time: f64| { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + }; + + pass(&mut app, 1.0); + app.run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap(); + assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the old document has a selection to clear"); + pass(&mut app, 2.0); + let old = app.session.active().unwrap().uid; + let slide = app.slide_rect.expect("the canvas has been laid out"); + // Well away from the inserted rectangle, so a press on the bare canvas clears the + // selection in the old document. + let point = egui::pos2(slide.min.x + slide.width() * 0.8, slide.min.y + slide.height() * 0.8); + app.queue_input(egui::Event::PointerMoved(point)); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let (req, reply) = + ControlRequest::new("engine.execute", json!({"command": "file.openBytes", "params": {"name": "destination.deckcraft", "data": data}})); + tx.send(req).unwrap(); + + // `raw_input_hook` admits one pointer event per pass. Every one of those passes must stay + // on the old target; the control request may not create the destination before ui sees + // the press/release. + for time in [3.0, 4.0, 5.0] { + pass(&mut app, time); + assert_eq!(app.session.active().unwrap().uid, old, "the click's pass stays on its captured target"); + assert_eq!(app.session.documents().len(), 1, "logic must not open the destination under the click"); + } + assert!(!app.synthetic_pending, "the full passes consumed the gesture"); + assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the press selected the bare canvas on the old document"); + + // With no synthetic input pending, the next logic applies the deferred request. The + // destination's full-slide shape is unselected because no pointer state from the old + // pass was replayed into it. + pass(&mut app, 6.0); + let reply = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the click"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.session.documents().len(), 2); + let active = app.session.active().unwrap(); + assert_ne!(active.uid, old, "the destination opened only after the click was consumed"); + assert!(!active.doc.slides[0].shapes.is_empty(), "the destination really has a clickable shape"); + assert!(active.selection.shapes.is_empty(), "the old pass's click did not select the destination shape"); +} + +/// A queued control click belongs to the full-screen show, opening window and palette +/// selection that were on screen when it was queued; each transition is its own target. +#[test] +fn input_target_tracks_slideshow_opening_and_palette_selection() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let base = app.input_target(); + app.start_show(0, true); + assert_ne!(app.input_target(), base, "a slideshow is a different input target"); + let started = app.input_target(); + app.show.as_mut().unwrap().state.step += 1; + let stepped = app.input_target(); + assert_ne!(stepped, started, "the show step changes which click areas are live"); + let reading_now = app.show.as_ref().unwrap().reading; + app.show.as_mut().unwrap().reading = !reading_now; + let reading = app.input_target(); + assert_ne!(reading, stepped, "the reading layout moves the slide"); + app.show.as_mut().unwrap().grid = true; + let grid = app.input_target(); + assert_ne!(grid, reading, "See All Slides turns a click into a thumbnail choice"); + app.show.as_mut().unwrap().ended = true; + let ended = app.input_target(); + assert_ne!(ended, grid, "the end screen has different click targets"); + app.show = None; + assert_eq!(app.input_target(), base, "leaving the show returns to the editor target"); + + // Starting the show again is a new instance even at the same slide, so an old batch cannot + // match by value after an ABA transition. + app.start_show(0, true); + let first_instance = app.input_target(); + app.start_show(0, true); + assert_ne!(app.input_target(), first_instance, "a new show instance is a new target"); + app.show = None; + + app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); + assert_ne!(app.input_target(), base, "the background-open window is a different target"); + let opening = app.input_target(); + app.opening.as_mut().unwrap().generation = 2; + assert_ne!(app.input_target(), opening, "reopening the same path is a new attempt"); + app.opening = None; + + app.palette = Some(("open".into(), 0)); + let first = app.input_target(); + app.palette = Some(("open".into(), 1)); + assert_ne!(app.input_target(), first, "the palette's highlighted entry is part of the target"); + app.palette = None; + + app.ui.tab = "Insert".into(); + assert_ne!(app.input_target(), base, "another ribbon tab is another click target"); + let insert = app.input_target(); + app.ui.ribbon_collapsed = true; + assert_ne!(app.input_target(), insert, "collapsing the ribbon changes the layout under a click"); + app.ui.ribbon_collapsed = false; + app.ribbon_scroll.insert("Insert".into(), 64.0_f32.to_bits()); + assert_ne!(app.input_target(), insert, "scrolling the ribbon moves the buttons under a click"); + app.ui.tab = "Home".into(); + app.ribbon_scroll.remove("Insert"); + assert_eq!(app.input_target(), base); +} + +/// A click queued for the editor is retracted when a slideshow, background-open window or +/// different palette entry takes over before the input pass. +#[test] +fn input_target_changes_retract_a_queued_click() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let pointer = || egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); + + app.queue_input(pointer()); + app.start_show(0, true); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for the editor does not enter the slideshow"); + assert!(app.synthetic.is_empty()); + app.show = None; + + app.queue_input(pointer()); + app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for the editor does not enter the opening window"); + app.opening = None; + + app.palette = Some(("open".into(), 0)); + app.queue_input(pointer()); + app.palette = Some(("open".into(), 1)); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for one palette selection does not activate another"); + assert!(app.synthetic.is_empty()); +} + +/// A logic-only callback does not consume events, but eframe carries the whole RawInput into +/// the next full pass. The hook injects without guessing the phase; when a control request +/// drained by that logic-only callback changes the document, the carried batch is retracted at +/// the next hook instead of being delivered into the new document. +#[test] +fn hidden_logic_pass_carries_a_synthetic_batch_and_drops_it_when_the_document_changes() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut hidden = egui::RawInput::default(); + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + assert_eq!(hidden.events.len(), 1, "the batch is injected into the RawInput eframe will carry"); + assert!(app.synthetic.is_empty()); + assert!(app.synthetic_pending, "run_logic leaves the batch unacknowledged"); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the hidden pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2, "the hidden pass opened another document"); + assert!(app.synthetic_pending, "a logic-only callback cannot retract the carried batch"); + + // eframe hands the hidden RawInput to the next visible pass, appending new host input. + let mut visible = hidden.clone(); + visible.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + let host = egui::Event::Text("host".into()); + visible.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut visible); + assert_eq!(visible.events, vec![host.clone()], "the click queued for the old document is dropped"); + assert!(app.synthetic.is_empty()); + ctx.run_ui(visible, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(app.synthetic.is_empty(), "nothing replays after the visible pass"); +} + +/// eframe may run logic-only for a *visible* root when a redraw deadline is overdue. The +/// previous pass's InputState is still live, so retraction must not drain an identical event +/// from it; the batch belongs to the carried RawInput and is removed at the next hook. +#[test] +fn visible_logic_pass_does_not_retract_from_the_previous_input_state() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let pointer = egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); + + // Live InputState from the previous full pass, with an identical event at the same index. + let mut previous = egui::RawInput { time: Some(1.0), ..Default::default() }; + previous.events.push(pointer.clone()); + ctx.run_ui(previous, |_ui| {}).drop_without_applying_deltas(); + + app.queue_input(pointer.clone()); + let mut raw = egui::RawInput { time: Some(2.0), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events, vec![pointer.clone()]); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the visible logic pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2); + assert!(app.synthetic_pending, "the carried batch is still pending"); + assert_eq!(ctx.input(|i| i.events.clone()), vec![pointer.clone()], "the previous pass's events are untouched"); + + let mut next = raw.clone(); + next.time = Some(3.0); + let host = egui::Event::Text("host".into()); + next.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut next); + assert_eq!(next.events, vec![host.clone()], "the stale batch is removed from the carried RawInput"); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty()); +} + +/// A batch injected in a logic-only pass is carried into the full pass with a refreshed +/// `RawInput::time`. That full pass must consume it on the old target: a control request +/// drained by the same logic waits until the next pass instead of changing the document +/// after egui has already derived the carried pointer state. +#[test] +fn carried_batch_is_consumed_before_the_full_pass_applies_a_control_change() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut hidden = egui::RawInput { time: Some(1.0), ..Default::default() }; + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.synthetic_pending, "the logic-only pass keeps the carried batch"); + + // eframe refreshes the carried input's time before calling the hook for the full pass. + let mut full = hidden.clone(); + full.time = Some(2.0); + app.raw_input_hook(&ctx, &mut full); + assert!(app.synthetic_pending, "the carried batch is not injected twice"); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + ctx.run_ui(full, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert_eq!(app.session.documents().len(), 1, "the full pass's UI sees its captured document"); + assert!(!app.synthetic_pending, "the full pass consumed the carried batch"); + + // The next pass has no synthetic input, so logic applies the deferred request. + let mut next = egui::RawInput { time: Some(3.0), ..Default::default() }; + next.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut next); + ctx.run_ui(next, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the full pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2, "the deferred control request ran on the next pass"); +} + +/// `visible() == false` does not prove a logic-only pass: a screenshot or +/// `RequestPaintWhileHidden` makes eframe run the full UI. The synthetic batch must be +/// delivered there, not postponed until the viewport is visible. +#[test] +fn hidden_full_pass_receives_synthetic_ui_input() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + app.queue_input(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + let ctx = egui::Context::default(); + let mut hidden = egui::RawInput { time: Some(4.0), ..Default::default() }; + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + assert_eq!(hidden.events.len(), 1, "a forced hidden full pass still gets the event"); + assert!(app.synthetic_pending); + + ctx.run_ui(hidden, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(app.dialog.is_none(), "the synthetic Escape reached the UI in the hidden full pass"); + assert!(!app.synthetic_pending, "the full pass acknowledged the batch"); + assert!(app.synthetic.is_empty()); +} + +/// egui 0.37 carries a clipboard image as `Event::PasteImage`; it inserts once, without +/// asking the host clipboard service for the same image again. +#[test] +fn paste_image_event_inserts_one_picture() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.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 before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)); + harness.event(egui::Event::PasteImage(std::sync::Arc::new(image))); + harness.run_steps(2); + let after = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + assert_eq!(after, before + 1, "one image is inserted"); +} + +/// A minimized/occluded show keeps playing from logic alone: the clock advances on wall +/// time, an After transition advances the slide without a draw, and a visible pass resumes +/// from the show state instead of inventing a paused-show policy. +#[test] +fn hidden_viewport_advances_show_and_presenter_clocks_from_logic() { + /// Nested helper in the hidden-show test: build root `RawInput` with the given time and + /// visibility flags. + fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + raw + } + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 0, "after": 1000})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); + app.start_show(0, false); + let ctx = egui::Context::default(); + + // A real visible UI pass starts the show and its clocks. There is no renderer here, so + // each frame's texture uploads are cleared explicitly (egui 0.37 panics on drop). + ctx.run_ui(raw_at(10.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let start = app.show.as_ref().expect("show started").clock_snapshot(); + assert_eq!(start.step_start, 10.0); + assert_eq!(start.show_start, 10.0); + + // Hidden logic starts at the last visible egui time, then advances on the monotonic + // instant. Two seconds later the slide's After: 1 s transition has advanced the show. + let t0 = web_time::Instant::now(); + app.show.as_mut().expect("show started").observe_visible_frame(10.0, t0); + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0)); + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(2))); + let hidden = app.show.as_ref().expect("hidden show"); + assert_eq!(hidden.state.slide, 1, "the After transition advances without a draw"); + assert!((hidden.clock_snapshot().slide_start - 12.0).abs() < 1e-9); + assert!((hidden.presenter_elapsed(12.0) - 2.0).abs() < 1e-9, "presenter time keeps running"); + + // A later hidden tick does not replay the transition or stack time. + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(3))); + assert_eq!(app.show.as_ref().unwrap().state.slide, 1); + assert!((app.show.as_ref().unwrap().clock_snapshot().slide_start - 12.0).abs() < 1e-9); + + // A visible pass re-anchors to egui's real time and keeps the state it reached hidden. + ctx.run_ui(raw_at(20.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let resumed = app.show.as_ref().expect("show resumed"); + assert_eq!(resumed.state.slide, 1); + assert!((resumed.presenter_elapsed(20.0) - 10.0).abs() < 1e-9); + assert!((resumed.clock_snapshot().slide_start - 12.0).abs() < 1e-9); +} + +/// A visible window can receive an overdue logic-only pass before the next paint. That pass +/// still carries the previous full frame's `InputState::time`; treating it as a visible frame +/// re-anchors the hidden clock to the later monotonic instant and loses elapsed show time. +/// Re-anchoring belongs to `show::ui`, which only runs on a real visible pass. +#[test] +fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { + /// Nested helper in the overdue-logic test: build root `RawInput` with the given time and + /// visibility flags. + fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + raw + } + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 0, "after": 2500})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); + app.start_show(0, false); + let ctx = egui::Context::default(); + + // The full visible pass is the only pass that anchors the show: egui time 10. + ctx.run_ui(raw_at(10.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let visible_anchor = web_time::Instant::now(); + + // An overdue visible logic-only pass does not refresh InputState::time. It must not move + // the hidden anchor from the full visible frame to this later instant. + let _ = ctx.run_logic(&raw_at(12.0, false, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(2))); + assert_eq!(ctx.input(|i| i.time), 10.0, "run_logic retains the last full pass's egui time"); + assert_eq!(app.show.as_ref().unwrap().state.slide, 0); + + // Three seconds after the full visible frame is past the slide's 2.5 s After delay. If the + // logic-only pass had re-anchored at +2 s, this hidden tick would still be only +1 s and + // the slide would not advance. + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); + assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); +} From 583f2dfb1b3058d58ee9ffbb8328823d71906dde Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:07 +0000 Subject: [PATCH 09/90] style(ui-egui): rustfmt tests.rs after the move --- crates/ui-egui/src/tests.rs | 70 ++++++++++--------------------------- 1 file changed, 19 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 9765fe858..0672d8c24 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -162,9 +162,8 @@ fn opening_a_file_with_missing_fonts_shows_a_toast_once() { /// #115: Change Picture replaced nothing — it inserted a second picture. #[test] fn change_picture_replaces_the_selected_picture() { - let svg = |c: &str| { - format!("").into_bytes() - }; + let svg = + |c: &str| format!("").into_bytes(); let media = |app: &SlideApp| { let d = app.session.active().unwrap(); let pics: Vec<_> = d.doc.slides[0] @@ -176,10 +175,9 @@ fn change_picture_replaces_the_selected_picture() { pics[0] }; let insert = |app: &mut SlideApp| { - let id = - app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() - ["id"] - .clone(); + let id = app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() + ["id"] + .clone(); app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); media(app) }; @@ -200,11 +198,8 @@ fn change_picture_replaces_the_selected_picture() { let inbox = Inbox::default(); let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let record = started.clone(); - let services = Services { - open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), - inbox: Some(inbox.clone()), - ..Default::default() - }; + let services = + Services { open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), inbox: Some(inbox.clone()), ..Default::default() }; let mut app = SlideApp::new(Session::with_new(), services); let before = insert(&mut app); app.run("app.changePictureDialog", json!({})).unwrap(); @@ -438,9 +433,8 @@ fn typing_goes_on_after_tab_in_a_text_box() { harness.event(egui::Event::Text("d".into())); harness.run_steps(2); assert_eq!(title_text(harness.state()), "ab\t\nd"); - let level = |h: &egui_kittest::Harness| { - h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level - }; + let level = + |h: &egui_kittest::Harness| h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level; assert_eq!(level(&harness), 1); } @@ -524,8 +518,7 @@ fn new_window_shows_the_same_document_on_its_own_slide() { fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { let mut app = SlideApp::new(Session::with_new(), Services::default()); assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); - let v = - app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + let v = app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); assert_eq!(v["spacing"], 72.0); assert_eq!(v["snap"], false); assert_eq!(v["smartGuides"], false); @@ -644,11 +637,7 @@ fn preferences_cancel_restores_the_settings_done_keeps_them() { unchanged(&harness, "Esc"); change(&mut harness); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Done") - .next() - .unwrap_or_else(|| panic!("no Done button")) - .click_accesskit(); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "Done").next().unwrap_or_else(|| panic!("no Done button")).click_accesskit(); harness.run_steps(2); let s = harness.state(); assert!(s.dialog.is_none(), "Done closes Preferences"); @@ -724,10 +713,9 @@ fn shape_x(app: &SlideApp, id: u64) -> f64 { fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() { let _gpu = crate::gpu_lock::gpu_lock(); 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(); + 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); @@ -1134,10 +1122,7 @@ fn editing_notes_in_the_pane_keeps_their_formatting() { harness.ctx.memory_mut(|m| m.surrender_focus(id)); harness.run_steps(2); let check = |n: &TextBody| { - assert_eq!( - n.text(), - "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" - ); + assert_eq!(n.text(), "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač"); assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); assert_eq!(n.paragraphs[1].props, ParaProps::default()); @@ -1626,8 +1611,7 @@ fn interface_size_applies_when_the_slider_is_let_go() { harness.state_mut().run("app.preferences", json!({})).unwrap(); harness.run_steps(4); let before = harness.state().ui.ui_scale; - let rect = - harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect(); + let rect = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect(); let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0)); harness.hover_at(from); harness.run_steps(1); @@ -1843,12 +1827,7 @@ fn a_control_click_on_the_first_font_discard_is_requeued_and_lands() { app.styled = false; app.fonts_ready = false; app.queue_input(egui::Event::PointerMoved(point)); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::NONE, - }); + app.queue_input(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); app.queue_input(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, @@ -1926,12 +1905,7 @@ fn synthetic_click_is_consumed_on_its_old_target_before_logic_changes_the_docume // selection in the old document. let point = egui::pos2(slide.min.x + slide.width() * 0.8, slide.min.y + slide.height() * 0.8); app.queue_input(egui::Event::PointerMoved(point)); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::NONE, - }); + app.queue_input(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); app.queue_input(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, @@ -2216,13 +2190,7 @@ fn hidden_full_pass_receives_synthetic_ui_input() { app.fonts_ready = true; app.styled = true; app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - app.queue_input(egui::Event::Key { - key: egui::Key::Escape, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }); + app.queue_input(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); let ctx = egui::Context::default(); let mut hidden = egui::RawInput { time: Some(4.0), ..Default::default() }; hidden.viewports.insert( From 03025647d6c262980ce0218e2fe96d07f1d3c1ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:28 +0000 Subject: [PATCH 10/90] refactor(ui-egui): move show tests to show/tests.rs --- crates/ui-egui/src/show.rs | 1007 +----------------------------- crates/ui-egui/src/show/tests.rs | 995 +++++++++++++++++++++++++++++ 2 files changed, 996 insertions(+), 1006 deletions(-) create mode 100644 crates/ui-egui/src/show/tests.rs diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index a26583910..403b6b593 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -2380,1009 +2380,4 @@ fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32 } #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use super::*; - - /// #1005: shapes stacked above a playing video must be drawn over its frame; the video, what - /// is below it and other videos stay out of that overlay. - #[test] - fn shapes_above_a_playing_video_are_the_overlay() { - let mut session = deckcraft_engine::Session::with_new(); - for _ in 0..3 { - session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, 100.0, 100.0]})).unwrap(); - } - let mut doc = (*session.active().unwrap().doc).clone(); - // Keep only the three inserted rects (a new slide may start with placeholders). - let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); - let keep = slide.shapes.len() - 3; - slide.shapes.drain(..keep); - let ids: Vec = slide.shapes.iter().map(|s| s.id).collect(); - slide.shapes[1].kind = deckcraft_model::ShapeKind::Media(deckcraft_model::MediaClip { video: true, ..Default::default() }); - // Video in the middle: the shape above it is drawn over it. - assert_eq!(below_playing_video(&doc, 0, &[ids[1]]), vec![ids[0], ids[1]]); - // Video on top: nothing above it, the frame is painted last as before. - std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.swap(1, 2); - assert!(below_playing_video(&doc, 0, &[ids[1]]).is_empty()); - // No video playing. - assert!(below_playing_video(&doc, 0, &[]).is_empty()); - } - - /// #968: over a shape a click acts on (a clip with its play trigger, a link) the pointing hand - /// never gave way to the idle hide, so a still pointer stayed on the playing video. - #[test] - fn a_still_cursor_hides_over_a_clickable_shape() { - let mut session = deckcraft_engine::Session::with_new(); - let size = session.active().unwrap().doc.slide_size; - let r = session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); - session.execute("insert.hyperlink", &json!({"url": "https://example.org", "ids": [r["id"]]})).unwrap(); - let app = SlideApp::new(session, crate::Services::default()); - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - harness.run_steps(3); - harness.state_mut().start_show(0, false); - harness.run_steps(2); - harness.hover_at(pos2(500.0, 350.0)); - harness.run_steps(2); - assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); - harness.run_steps(12); - assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden"); - } - - /// #1130: the Presenter View's All slides button opens the grid in that window, not on the audience screen. - #[test] - fn presenter_all_slides_does_not_cover_the_audience_screen() { - use egui_kittest::kittest::Queryable; - - let mut session = deckcraft_engine::Session::with_new(); - session.execute("slide.new", &json!({})).unwrap(); - let doc = session.active().unwrap().doc.clone(); - let show = Show::new(&doc, 0, false, true); - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( - |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { - let now = ui.input(|i| i.time); - presenter_window(ui.ctx(), show, doc, now, keep); - }, - (doc, show, true), - ); - harness.run_steps(2); - harness.get_by_label("All slides").click(); - harness.run_steps(2); - assert!(harness.state().1.presenter_grid, "the grid opens in the Presenter View"); - assert!(!harness.state().1.grid, "the audience screen keeps showing the slide"); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(!harness.state().1.presenter_grid, "Esc closes the Presenter View grid"); - assert!(harness.state().2, "and does not end the show"); - harness.key_press(egui::Key::G); - harness.run_steps(2); - assert!(harness.state().1.presenter_grid, "G in Presenter View opens its own grid"); - assert!(!harness.state().1.grid, "G in Presenter View leaves the audience screen alone"); - harness.key_press(egui::Key::G); - harness.run_steps(2); - assert!(!harness.state().1.presenter_grid, "G again closes it"); - } - - /// A silent mono 8 kHz WAV, so the clip has a real duration to seek in. - fn silent_wav(secs: u32) -> Vec { - let rate = 8_000u32; - let data = vec![0u8; (rate * secs * 2) as usize]; - let mut b = Vec::new(); - b.extend_from_slice(b"RIFF"); - b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); - b.extend_from_slice(b"WAVEfmt "); - b.extend_from_slice(&16u32.to_le_bytes()); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&rate.to_le_bytes()); - b.extend_from_slice(&(rate * 2).to_le_bytes()); - b.extend_from_slice(&2u16.to_le_bytes()); - b.extend_from_slice(&16u16.to_le_bytes()); - b.extend_from_slice(b"data"); - b.extend_from_slice(&(data.len() as u32).to_le_bytes()); - b.extend_from_slice(&data); - b - } - - /// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the - /// bar's seek track, and what `pointer_on_media_bar` said on the last frame. - fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc>) { - let size = vec2(1000.0, 700.0); - let mut session = deckcraft_engine::Session::with_new(); - let id = ShapeId(4000); - session - .edit(|doc, _| { - let media = doc.add_media("clip.wav", "audio/wav", silent_wav(10)).unwrap(); - let (w, h) = (doc.slide_size.width, doc.slide_size.height); - let clip = deckcraft_model::MediaClip { media, video: true, volume: 1.0, duration_ms: 10_000, ..Default::default() }; - let xfrm = deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6); - let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); - slide.shapes.push(deckcraft_model::Shape { - id, - xfrm: Some(xfrm), - kind: deckcraft_model::ShapeKind::Media(clip), - ..Default::default() - }); - Ok(()) - }) - .unwrap(); - let on_bar = std::rc::Rc::new(std::cell::Cell::new(false)); - let seen = on_bar.clone(); - let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING)); - let slide_seen = slide.clone(); - // Presenter view opens an embedded window over the whole screen here, which takes the clicks. - let mut app = SlideApp::new(session, crate::Services::default()); - app.ui.presenter_view = false; - let mut harness = egui_kittest::Harness::builder().with_size(size).with_step_dt(0.25).build_ui_state( - move |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) { - let now = ui.input(|i| i.time); - seen.set(pointer_on_media_bar(ui.ctx(), ui, &d.doc, show, now)); - slide_seen.set(shown_slide_rect(ui, show, &d.doc)); - } - app.ui(ui); - }, - app, - ); - harness.run_steps(3); - harness.state_mut().start_show(0, false); - harness.run_steps(2); - let doc = harness.state().session.active().unwrap().doc.clone(); - let bar = slide_media_bars(slide.get(), &doc, 0)[0].3; - let track = pos2(bar.min.x + bar.width() * 0.8, bar.center().y); - (harness, id, track, on_bar) - } - - /// Press and release the primary button at `p`. - fn click_at(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { - for pressed in [true, false] { - harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - harness.step(); - } - } - - /// #967: hovering a clip shows its bar and a click on the bar seeks. After 3 s without moving - /// the bar is gone, and a click where it was acts on the clip itself (play), not on the bar. - #[test] - fn media_controls_hide_when_pointer_is_idle() { - // Awake: the bar is there and a click on its timeline seeks, leaving the clip paused. - let (mut harness, id, track, on_bar) = show_with_clip(); - harness.hover_at(track); - harness.run_steps(2); - assert!(harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); - assert!(on_bar.get(), "the bar is under a pointer that just moved"); - click_at(&mut harness, track); - harness.run_steps(2); - let status = harness.state().media.status(id).expect("the click on the bar cued the clip"); - assert!(!harness.state().media.is_playing(id), "a bar click seeks, it does not play"); - assert!(status.position > 1.0, "seeked along the timeline, at {}", status.position); - - // Idle: same spot, no movement for 3 s. - let (mut harness, id, track, on_bar) = show_with_clip(); - harness.hover_at(track); - harness.run_steps(2); - assert!(on_bar.get()); - harness.run_steps(12); - assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); - assert!(!on_bar.get(), "the bar is hidden once the pointer is idle"); - click_at(&mut harness, track); - harness.run_steps(2); - assert!(harness.state().media.is_playing(id), "the click reached the clip"); - let at = harness.state().media.status(id).map_or(0.0, |s| s.position); - assert!(at < 5.0, "the click did not seek along the timeline, position {at}"); - } - - /// Two 1×1 frames (black, then white), 100 ms each. - fn two_frame_gif() -> Vec { - let mut b = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff".to_vec(); - for pixel in [b"\x44\x01", b"\x4c\x01"] { - b.extend_from_slice(b"\x21\xf9\x04\x00\x0a\x00\x00\x00"); - b.extend_from_slice(b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02"); - b.extend_from_slice(pixel); - b.push(0); - } - b.push(0x3b); - b - } - - /// A running show on the first of two slides; slide 1 holds a large animated GIF. Returns the - /// harness, the GIF, and its play/pause button's centre. - fn show_with_gif() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2) { - let mut session = deckcraft_engine::Session::with_new(); - // The GIF is on slide 1, the slide the editor has selected, with a slide after it. - session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); - session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); - session.execute("slide.go", &json!({"index": 1})).unwrap(); - let id = ShapeId(4100); - session - .edit(|doc, _| { - let media = doc.add_media("a.gif", "image/gif", two_frame_gif()).unwrap(); - let (w, h) = (doc.slide_size.width, doc.slide_size.height); - let slide = std::sync::Arc::make_mut(&mut doc.slides[1]); - slide.shapes.push(deckcraft_model::Shape { - id, - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6)), - kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, - ..Default::default() - }); - Ok(()) - }) - .unwrap(); - let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING)); - let slide_seen = slide.clone(); - let mut app = SlideApp::new(session, crate::Services::default()); - app.ui.presenter_view = false; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state( - move |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) { - slide_seen.set(shown_slide_rect(ui, show, &d.doc)); - } - app.ui(ui); - }, - app, - ); - harness.run_steps(3); - harness.state_mut().start_show(1, false); - harness.run_steps(2); - let doc = harness.state().session.active().unwrap().doc.clone(); - let srect = slide.get(); - let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - let rect = doc.slides[1].shapes.last().unwrap().xfrm.unwrap(); - let button = crate::media::gif_button_rect(on_screen(srect, k, &rect)).center(); - (harness, id, button) - } - - /// The GIF's play/pause button is drawn only while the pointer is awake, so a click on its - /// spot after 2.5 s without movement advances the show instead of pausing the GIF. - #[test] - fn gif_button_click_needs_an_awake_pointer() { - let (mut harness, id, button) = show_with_gif(); - harness.hover_at(button); - harness.run_steps(2); - click_at(&mut harness, button); - harness.run_steps(2); - assert!(harness.state().session.gif_paused.contains(&id), "an awake pointer's click pauses the GIF"); - assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1); - - let (mut harness, id, button) = show_with_gif(); - harness.hover_at(button); - harness.run_steps(2); - harness.run_steps(12); - assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); - click_at(&mut harness, button); - harness.run_steps(2); - assert!(!harness.state().session.gif_paused.contains(&id), "the hidden button is not clickable"); - assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 2, "the click advanced the show"); - } - - /// Send real egui key events to the presenter UI, without the audience window's handler. - #[test] - fn presenter_navigation_uses_its_own_keyboard_events() { - use egui_kittest::kittest::Queryable; - - let mut session = deckcraft_engine::Session::with_new(); - session.execute("slide.new", &json!({})).unwrap(); - session.execute("slide.new", &json!({})).unwrap(); - let doc = session.active().unwrap().doc.clone(); - let show = Show::new(&doc, 0, false, true); - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( - |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { - let now = ui.input(|i| i.time); - presenter_window(ui.ctx(), show, doc, now, keep); - }, - (doc, show, true), - ); - harness.run_steps(2); - for (key, slide) in [ - (egui::Key::ArrowRight, 1), - (egui::Key::ArrowLeft, 0), - (egui::Key::Space, 1), - (egui::Key::PageDown, 2), - (egui::Key::PageUp, 1), - (egui::Key::Home, 0), - (egui::Key::End, 2), - ] { - harness.key_press(key); - harness.run_steps(2); - assert_eq!(harness.state().1.state.slide, slide, "presenter key {key:?}"); - } - harness.key_press(egui::Key::ArrowRight); - harness.run_steps(2); - assert!(harness.state().1.ended); - harness.key_press(egui::Key::ArrowLeft); - harness.run_steps(2); - assert!(!harness.state().1.ended); - assert_eq!(harness.state().1.state.slide, 2, "back from the end screen restores the last slide"); - harness.key_press(egui::Key::ArrowRight); - harness.run_steps(2); - assert!(harness.state().1.ended); - harness.get_by_label("◀ Previous").click(); - harness.run_steps(2); - assert!(!harness.state().1.ended, "the presenter Previous button also leaves the end screen"); - harness.key_press(egui::Key::Home); - harness.run_steps(2); - harness.get_by_label("Next ▶").click(); - harness.run_steps(2); - assert_eq!(harness.state().1.state.slide, 1); - harness.key_press(egui::Key::Space); - harness.run_steps(2); - assert_eq!(harness.state().1.state.slide, 2); - assert!(!harness.state().1.ended, "Space must not also activate the focused Next button"); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(!harness.state().2, "Escape from Presenter View ends the show"); - } - - #[test] - fn slideshow_navigation_advances_once_with_presenter_view() { - use egui_kittest::kittest::Queryable; - - let mut session = deckcraft_engine::Session::with_new(); - session.execute("slide.new", &json!({})).unwrap(); - session.execute("slide.new", &json!({})).unwrap(); - let mut app = SlideApp::new(session, crate::Services::default()); - app.ui.presenter_view = true; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - harness.run_steps(3); - harness.state_mut().start_show(0, false); - harness.run_steps(2); - harness.key_press(egui::Key::ArrowRight); - harness.run_steps(2); - assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1, "one key advances one slide"); - harness.key_press(egui::Key::End); - harness.run_steps(2); - harness.key_press(egui::Key::ArrowRight); - harness.run_steps(2); - assert!(harness.state().show.as_ref().unwrap().ended); - assert!(harness.query_by_label("End Show").is_some(), "the presenter remains available at the end screen"); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(harness.state().show.is_none()); - } - - #[test] - fn the_prefetched_frame_is_the_frame_shown_on_entry() { - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({"title": "Second"})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let size = (96, 54); - let p = start_prefetch(&doc, 1, size).expect("prefetch starts"); - assert_eq!(p.key, Key { slide: 1, step: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: vec![], gif: 0, plays: vec![] }); - let got = p.rx.recv().expect("the frame arrives"); - let want = render_frame(&doc, 1, ShowState::timeline(&doc, 1), 0, 0.0, size, vec![], &[], 0, &[], &[], None); - assert_eq!(got.size, want.size); - assert_eq!(got.pixels, want.pixels); - } - - /// A glowing full-slide picture in a layout made every animation frame re-render (and re-blur) - /// the backdrop: 2–3 frames a second. Frames now draw the slide's shapes over the kept - /// backdrop, and must look the same as a whole-slide render. - #[test] - fn an_animation_frame_over_the_kept_backdrop_matches_a_full_render() { - let mut s = deckcraft_engine::Session::with_new(); - let r = s.execute("shape.insert", &json!({"preset": "star5", "rect": [100.0, 100.0, 300.0, 200.0]})).unwrap(); - s.execute("animation.set", &json!({"effect": "fade", "ids": [r["id"]]})).unwrap(); - let mut doc = (*s.active().unwrap().doc).clone(); - // A glowing master graphic under the animated star. - let mut deco = doc.slides[0].shapes.last().unwrap().clone(); - deco.id = ShapeId(9000); - deco.xfrm = deco.xfrm.map(|x| deckcraft_geom::Xfrm { x: x.x - 60.0, y: x.y + 40.0, ..x }); - deco.effects = Some(deckcraft_model::Effects { - glow: Some(deckcraft_model::style::Glow { - color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 120, 255)), - radius: 12.0, - }), - ..Default::default() - }); - std::sync::Arc::make_mut(&mut doc.masters[0]).shapes.push(deco); - let doc = std::sync::Arc::new(doc); - let size = (480, 270); - let key = BackKey { slide: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize }; - let mut show = Show::new(&doc, 0, false, false); - show.prefetch_backdrop(&doc, key); - let back = show.backdrop(&doc, key); - let tl = ShowState::timeline(&doc, 0); - assert!(!tl.is_empty()); - // Half-way through the fade, the star half see-through over the glow. - let fast = render_frame(&doc, 0, tl.clone(), 0, 0.25, size, vec![], &[], 0, &[], &[], Some(&back)); - let full = render_frame(&doc, 0, tl, 0, 0.25, size, vec![], &[], 0, &[], &[], None); - assert_eq!(fast.size, full.size); - let worst = fast - .pixels - .iter() - .zip(&full.pixels) - .map(|(a, b)| a.to_array().iter().zip(b.to_array()).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) - .max(); - assert!(worst.is_some_and(|d| d <= 2), "frames differ by up to {worst:?}"); - // The backdrop is kept, not rendered again. - assert!(std::sync::Arc::ptr_eq(&back, &show.backdrop(&doc, key))); - // Another document under the same key (an edit whose copy reused the old address) gets - // its own backdrop. - let edited = std::sync::Arc::new((*doc).clone()); - assert!(!std::sync::Arc::ptr_eq(&back, &show.backdrop(&edited, key))); - } - - #[test] - fn a_transition_keeps_the_old_slide_in_its_own_texture() { - // #33: the old slide's handle was cloned, so the new slide was rendered into the same - // texture and every transition blended the new slide with itself. - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let ctx = egui::Context::default(); - let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); - let mut show = Show::new(&doc, 0, false, false); - show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone())); - assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); - let tr = show.trans.as_ref().expect("the fade plays"); - assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id())); - assert_eq!(tr.from, Some(0)); - assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); - } - - #[test] - fn media_in_the_click_sequence_plays_with_its_step() { - // #116: media set to play in the click sequence never started; only a click on it did. - let s = deckcraft_engine::Session::with_new(); - let mut doc = s.active().unwrap().doc.clone(); - let id = doc.slides[0].shapes[0].id; - let play = |delay_ms| deckcraft_model::Animation { - shape: id, - class: deckcraft_model::AnimClass::Media, - effect: "play".into(), - duration_ms: 5000, - delay_ms, - ..Default::default() - }; - std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut doc).slides[0]).animations = vec![play(0), play(2000)]; - let mut show = Show::new(&doc, 0, false, false); - assert!(show.apply(ShowAction::Slide(0), &doc, 0.0)); - // Next: the first step plays and sets off its Play once. - let a = show.state.next(&doc); - assert_eq!(a, ShowAction::PlayStep(0)); - assert!(show.apply(a, &doc, 1.0)); - show.cue_media(&doc, 0, 0.0); - show.cue_media(&doc, 0, 0.5); - assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); - // The step doesn't wait for the video: the step clock ends it at once. - assert_eq!(show.timeline(&doc).step_duration(0), 0.0); - let a = show.state.step_done(&doc); - assert!(show.apply(a, &doc, 1.0)); - assert!(show.media_cues.is_empty(), "set off once"); - // Next again, before the second Play's delay: cutting the step short still sets it off. - let a = show.state.next(&doc); - assert_eq!(a, ShowAction::PlayStep(1)); - assert!(show.apply(a, &doc, 2.0)); - show.cue_media(&doc, 1, 0.5); - assert!(show.media_cues.is_empty()); - let a = show.state.step_done(&doc); - assert!(show.apply(a, &doc, 2.6)); - assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); - // Going back doesn't set anything off. - let a = show.state.prev(&doc); - assert!(show.apply(a, &doc, 3.0)); - show.cue_media(&doc, 1, 9.0); - assert!(show.media_cues.is_empty()); - } - - #[test] - fn the_laser_and_the_pen_are_not_on_together() { - let doc = deckcraft_model::Presentation::default(); - let mut show = Show::new(&doc, 0, false, false); - show.set_laser(true); - assert!(show.laser && !show.pen); - show.set_pen(true); - assert!(show.pen && !show.laser); - show.set_laser(true); - show.arrow(); - assert!(!show.laser && !show.pen); - show.set_laser(false); - assert!(!show.laser); - } - - fn deck_with_first_animation(start: deckcraft_model::anim::AnimStart) -> Presentation { - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - let mut doc = Presentation::clone(&s.active().unwrap().doc); - let slide = std::sync::Arc::make_mut(&mut doc.slides[1]); - let shape = slide.shapes[0].id; - slide.animations = vec![deckcraft_model::anim::Animation { shape, start, duration_ms: 3000, ..Default::default() }]; - doc - } - - #[test] - fn a_first_animation_after_or_with_previous_plays_when_the_slide_appears() { - // #631: it was started twice and the second start finished it, so the slide appeared - // fully built. - use deckcraft_model::anim::AnimStart; - for start in [AnimStart::AfterPrevious, AnimStart::WithPrevious] { - let doc = deck_with_first_animation(start); - // Starting the show on the slide. - let mut show = Show::new(&doc, 1, false, false); - show.after_enter(&doc, 5.0); - assert_eq!((show.state.step, show.state.playing), (0, true), "{start:?} from the start"); - assert_eq!(show.step_start, 5.0); - // Arriving from the slide before. - let mut show = Show::new(&doc, 0, false, false); - let a = show.state.next(&doc); - assert!(matches!(a, ShowAction::Slide(1))); - assert!(show.apply(a, &doc, 7.0)); - assert_eq!((show.state.step, show.state.playing), (0, true), "{start:?} on arrival"); - } - // On Click still waits for the click. - let doc = deck_with_first_animation(AnimStart::OnClick); - let mut show = Show::new(&doc, 1, false, false); - show.after_enter(&doc, 5.0); - assert_eq!((show.state.step, show.state.playing), (0, false)); - assert!(matches!(show.state.next(&doc), ShowAction::PlayStep(0))); - } - - #[test] - fn zoom_stays_on_the_slide_and_the_grid_hits_a_thumbnail() { - let uv = zoom_uv(0.5, 0.5, 2.0); - assert!((uv.min.x - 0.25).abs() < 1e-4 && (uv.min.y - 0.25).abs() < 1e-4); - assert!((uv.width() - 0.5).abs() < 1e-4); - let corner = zoom_uv(0.0, 0.0, 2.0); - assert_eq!(corner.min, pos2(0.0, 0.0)); - assert!(corner.max.x <= 1.0 && corner.max.y <= 1.0); - let full = Rect::from_min_size(Pos2::ZERO, vec2(1728.0, 1084.0)); - let cells = see_all_cells(full, 7, 16.0 / 9.0); - assert_eq!(cells.len(), 7); - for (i, a) in cells.iter().enumerate() { - assert!(full.contains_rect(*a), "cell {i} leaves the window"); - for b in cells.iter().skip(i + 1) { - let overlap = a.intersect(*b); - assert!(overlap.width() < 1.0 || overlap.height() < 1.0, "thumbnails overlap {a:?} {b:?}"); - } - } - let hit = see_all_hit(full, 7, 16.0 / 9.0, cells[3].center()); - assert_eq!(hit, Some(3)); - assert_eq!(see_all_hit(full, 7, 16.0 / 9.0, pos2(2.0, 2.0)), None); - let srect = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 100.0)); - assert_eq!(norm_on_slide(srect, unit_uv(), pos2(110.0, 70.0)), Some((0.5, 0.5))); - let zoomed = zoom_uv(0.5, 0.5, 2.0); - let back = norm_on_slide(srect, zoomed, pos2(110.0, 70.0)).expect("on the slide"); - assert!((back.0 - 0.5).abs() < 1e-3 && (back.1 - 0.5).abs() < 1e-3); - } - - fn near(a: f32, b: f32) -> bool { - (a - b).abs() < 1e-3 - } - - /// #1286: Ctrl+wheel zooms around the pointer, four notches per doubling, up to 4× (the most - /// PowerPoint's magnifier goes); zooming back to the whole slide leaves the zoom. - #[test] - fn wheel_zoom_keeps_the_point_under_the_pointer_and_stops_at_4x() { - let doc = deckcraft_model::Presentation::default(); - let mut show = Show::new(&doc, 0, false, false); - // A 16:9 slide, not at the origin, and a pointer off its centre. - let srect = Rect::from_min_size(pos2(40.0, 30.0), vec2(320.0, 180.0)); - let p = pos2(40.0 + 320.0 * 0.2, 30.0 + 180.0 * 0.3); - show.wheel_zoom(srect, p, 4.0); - let (_, _, scale) = show.zoom_view().expect("zoomed in"); - assert!(near(scale, 2.0), "four notches double, got {scale}"); - // The window keeps the slide's shape: half the width and half the height of the slide. - let uv = show.zoom_uv(); - assert!(near(uv.width(), 0.5) && near(uv.height(), 0.5), "{uv:?}"); - assert!(near(uv.min.x, 0.2 - 0.5 * 0.2) && near(uv.min.y, 0.3 - 0.5 * 0.3), "{uv:?}"); - let under = norm_on_slide(srect, uv, p).expect("on the slide"); - assert!(near(under.0, 0.2) && near(under.1, 0.3), "the point under the pointer moved to {under:?}"); - - // Again from the zoomed view: the same slide point stays under the pointer. - show.wheel_zoom(srect, p, 2.0); - let (_, _, scale) = show.zoom_view().expect("zoomed in"); - assert!(near(scale, 2f32.powf(1.5)), "{scale}"); - let under = norm_on_slide(srect, show.zoom_uv(), p).expect("on the slide"); - assert!(near(under.0, 0.2) && near(under.1, 0.3), "{under:?}"); - - show.wheel_zoom(srect, p, 40.0); - let (_, _, scale) = show.zoom_view().expect("zoomed in"); - assert!(near(scale, 4.0), "capped at 4×, got {scale}"); - assert!(near(show.zoom_uv().width(), 0.25)); - - show.wheel_zoom(srect, p, -7.0); - assert!(show.zoom_view().is_some(), "one notch short of the whole slide is still zoomed"); - show.wheel_zoom(srect, p, -1.0); - assert!(show.zoom_view().is_none(), "back to the whole slide leaves the zoom"); - assert_eq!(show.zoom_uv(), unit_uv()); - show.wheel_zoom(srect, p, -3.0); - assert!(show.zoom_view().is_none(), "zooming out of the whole slide does nothing"); - - // Off the slide (the letterbox) the wheel does not zoom. - show.wheel_zoom(srect, pos2(10.0, 10.0), 4.0); - assert!(show.zoom_view().is_none()); - // An armed click-zoom gives way to the wheel. - show.zoom_arm = true; - show.wheel_zoom(srect, p, 4.0); - assert!(!show.zoom_arm && show.zoom_view().is_some()); - } - - #[test] - fn wheel_zoom_reads_only_the_wheel_with_ctrl_or_cmd() { - let wheel = |unit, y: f32, modifiers| egui::Event::MouseWheel { - unit, - delta: vec2(0.0, y), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers, - }; - use egui::MouseWheelUnit::{Line, Point}; - assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::CTRL)], 40.0), Some(1.0)); - assert_eq!(wheel_zoom_lines(&[wheel(Line, -2.0, egui::Modifiers::COMMAND)], 40.0), Some(-2.0)); - assert_eq!(wheel_zoom_lines(&[wheel(Point, 20.0, egui::Modifiers::CTRL), wheel(Point, 20.0, egui::Modifiers::CTRL)], 40.0), Some(1.0)); - assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::NONE)], 40.0), None, "a plain wheel turns the page"); - assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::SHIFT)], 40.0), None); - assert_eq!(wheel_zoom_lines(&[egui::Event::Zoom(2.0)], 40.0), None, "a pinch is not a show gesture"); - assert_eq!(wheel_zoom_lines(&[wheel(Line, f32::NAN, egui::Modifiers::CTRL)], 40.0), Some(0.0)); - } - - /// A running show on the first of three slides, with presenter view off. - fn show_of_three() -> egui_kittest::Harness<'static, SlideApp> { - let mut session = deckcraft_engine::Session::with_new(); - session.execute("slide.new", &json!({})).unwrap(); - session.execute("slide.new", &json!({})).unwrap(); - let mut app = SlideApp::new(session, crate::Services::default()); - app.ui.presenter_view = false; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - harness.run_steps(3); - harness.state_mut().start_show(0, false); - harness.run_steps(2); - harness - } - - fn show_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, at: Pos2, y: f32, modifiers: egui::Modifiers) { - harness.input_mut().events.push(egui::Event::PointerMoved(at)); - harness.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: vec2(0.0, y), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers, - }); - harness.run_steps(40); - } - - /// #1286: in the show, Ctrl+wheel (Cmd+wheel on a Mac) zooms the slide and does not turn the - /// page; the plain wheel still turns it. - #[test] - fn ctrl_wheel_zooms_the_show_and_the_plain_wheel_turns_the_page() { - for held in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { - let mut h = show_of_three(); - let centre = pos2(500.0, 350.0); - show_wheel(&mut h, centre, 4.0, held); - let show = h.state().show.as_ref().expect("still showing"); - assert_eq!(show.state.slide, 0, "{held:?}: the zoom wheel does not turn the page"); - let (_, _, scale) = show.zoom_view().expect("zoomed in"); - assert!(near(scale, 2.0), "{held:?}: {scale}"); - - show_wheel(&mut h, centre, -4.0, held); - let show = h.state().show.as_ref().expect("still showing"); - assert_eq!(show.state.slide, 0, "{held:?}: zooming out does not go back a page either"); - assert!(show.zoom_view().is_none(), "{held:?}: back to the whole slide"); - - show_wheel(&mut h, centre, -3.0, egui::Modifiers::NONE); - assert_eq!(h.state().show.as_ref().expect("still showing").state.slide, 1, "the plain wheel turns the page"); - } - } - - #[test] - fn escape_only_closes_the_grid_of_its_own_window() { - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let mut show = Show::new(&doc, 0, false, true); - show.presenter_grid = true; - assert!(!show.back_out(false), "audience Esc leaves the show, the Presenter View grid stays"); - assert!(show.presenter_grid); - assert!(show.back_out(true)); - assert!(!show.presenter_grid); - show.grid = true; - assert!(!show.back_out(true), "Presenter View Esc does not close the audience grid"); - assert!(show.grid); - assert!(show.back_out(false)); - assert!(!show.grid); - } - - #[test] - fn escape_leaves_the_grid_then_the_zoom_and_blank_toggles() { - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let mut show = Show::new(&doc, 0, false, true); - show.grid = true; - show.zoom = Some(Zoom::at(0.2, 0.3)); - assert!(show.back_out(false)); - assert!(!show.grid); - assert!(show.zoom.is_some(), "closing the grid leaves the zoom"); - assert!(show.back_out(false)); - assert!(show.zoom.is_none()); - assert!(!show.back_out(false)); - show.zoom_key(); - assert!(show.zoom_arm); - show.zoom_key(); - assert!(!show.zoom_arm); - toggle_blank(&mut show, Color32::BLACK); - assert_eq!(show.blank_name(), Some("black")); - toggle_blank(&mut show, Color32::WHITE); - assert_eq!(show.blank_name(), Some("white")); - toggle_blank(&mut show, Color32::WHITE); - assert_eq!(show.blank_name(), None); - show.zoom = Some(Zoom::at(0.4, 0.6)); - assert!(show.apply(ShowAction::Slide(1), &doc, 1.0)); - assert!(show.zoom.is_none(), "a new slide is shown whole"); - } - - /// #1128: the presenter drew the notes as plain text, so bullets, numbers and bold were lost - /// although the notes pane shows them; the notes stay selectable. - #[test] - fn presenter_notes_keep_bullets_numbers_and_bold() { - use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; - let point = |text: &str, bullet: Bullet| Paragraph { - level: 1, - props: ParaProps { bullet: Some(bullet), ..Default::default() }, - runs: vec![Run::new(text)], - ..Default::default() - }; - let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; - let notes = TextBody { - paragraphs: vec![ - Paragraph { runs: vec![Run::with("Opening", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, - point("Welcome", Bullet::Char { char: "•".into() }), - point("Agenda", num()), - point("Demo", num()), - ], - ..Default::default() - }; - let ctx = egui::Context::default(); - theme::install_fonts(&ctx); - let mut sense = None; - let mut out = ctx.run_ui(Default::default(), |ui| sense = Some(presenter_notes(ui, ¬es, 24.0).sense)); - out.textures_delta.clear(); - assert!(sense.unwrap().senses_drag(), "the notes can be selected, as the plain label could"); - let mut texts = vec![]; - let mut stack: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); - while let Some(shape) = stack.pop() { - match shape { - egui::Shape::Vec(v) => stack.extend(v), - egui::Shape::Text(t) => texts.push(t.galley), - _ => {} - } - } - let painted: Vec<&str> = texts.iter().map(|g| g.job.text.as_str()).collect(); - for marker in ["•", "1.", "2."] { - assert!(painted.contains(&marker), "{marker} is drawn: {painted:?}"); - } - let notes_galley = texts.iter().find(|g| g.job.text.contains("Agenda")).expect("the notes are drawn"); - let font_of = |s: &str| { - let b = notes_galley.job.text.find(s).unwrap(); - notes_galley.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone() - }; - assert_eq!(font_of("Opening"), theme::bold(24.0), "bold runs stay bold at the pane's size"); - assert_eq!(font_of("Agenda"), theme::font(24.0)); - } - - /// Hidden logic keeps playing from wall time even though `run_logic` leaves egui's input - /// time at the last visible frame. A visible pass re-anchors instead of shifting a pause. - #[test] - fn hidden_logic_advances_the_show_and_visible_frames_reanchor() { - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let mut show = Show::new(&doc, 0, false, true); - show.started = true; - show.step_start = 10.0; - show.slide_start = 10.0; - show.step_end = 10.0; - show.show_start = 10.0; - show.last_move = 10.0; - show.timer = PresenterTimer::start(10.0); - let t0 = web_time::Instant::now(); - show.observe_visible_frame(10.0, t0); - assert_eq!(show.hidden_logic_time(10.0, t0), 10.0, "the first hidden tick starts at the last visible time"); - let hidden = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(2)); - assert!((hidden - 12.0).abs() < 1e-9, "hidden logic advances the show time: {hidden}"); - assert!((show.presenter_elapsed(hidden) - 2.0).abs() < 1e-9, "presenter time keeps running"); - assert!((show.clock_snapshot().step_start - 10.0).abs() < 1e-9, "the clock origin is not shifted"); - let later = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(5)); - assert!((later - 15.0).abs() < 1e-9, "repeated hidden ticks do not stack a pause: {later}"); - show.observe_visible_frame(20.0, t0 + std::time::Duration::from_secs(10)); - assert_eq!(show.hidden_logic_time(20.0, t0 + std::time::Duration::from_secs(10)), 20.0, "a visible frame re-anchors"); - } - - #[test] - fn presenter_timer_pauses_resets_and_notes_size_clamps() { - let mut t = PresenterTimer::start(10.0); - assert!((t.elapsed(12.5) - 2.5).abs() < 1e-9); - t.toggle(15.0); - assert!(t.paused()); - assert!((t.elapsed(100.0) - 5.0).abs() < 1e-9); - t.reset(100.0); - assert!(t.paused()); - assert_eq!(t.elapsed(200.0), 0.0); - t.toggle(200.0); - assert!(!t.paused()); - assert!((t.elapsed(203.0) - 3.0).abs() < 1e-9); - t.reset(203.0); - assert!(!t.paused()); - assert!((t.elapsed(204.0) - 1.0).abs() < 1e-9); - let bad = PresenterTimer::start(f64::NAN); - assert_eq!(bad.elapsed(f64::INFINITY), 0.0); - assert_eq!(format_elapsed(3661.4), "01:01:01"); - assert_eq!(format_elapsed(-1.0), "00:00:00"); - assert_eq!(format_elapsed(f64::NAN), "00:00:00"); - let clock = wall_clock(); - assert!(clock == "--:--" || (clock.len() == 5 && clock.as_bytes().get(2) == Some(&b':'))); - - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let mut show = Show::new(&doc, 0, false, true); - assert_eq!(show.notes_px, NOTES_DEFAULT); - show.nudge_notes(NOTES_STEP); - assert_eq!(show.notes_px, 22.0); - show.nudge_notes(1000.0); - assert_eq!(show.notes_px, NOTES_MAX); - show.nudge_notes(-1000.0); - assert_eq!(show.notes_px, NOTES_MIN); - show.nudge_notes(f32::NAN); - assert_eq!(show.notes_px, NOTES_MIN); - let v = show.showing_value(1, 16.0 / 9.0, Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), 0.0); - assert_eq!(v["presenterPaused"], json!(false)); - assert_eq!(v["notesPx"], json!(NOTES_MIN)); - } - - #[test] - fn a_typed_slide_number_jumps_and_then_expires() { - let mut s = deckcraft_engine::Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let mut show = Show::new(&doc, 0, false, false); - show.push_slide_number('1', 10.0); - show.push_slide_number('2', 10.2); - assert_eq!(show.goto, "12"); - assert_eq!(show.take_goto(), Some(11)); - assert!(show.goto.is_empty()); - show.push_slide_number('0', 1.0); - assert_eq!(show.take_goto(), None, "slide 0 is not a slide"); - show.push_slide_number('4', 5.0); - show.expire_goto(7.0); - assert_eq!(show.goto, "4"); - show.expire_goto(8.1); - assert!(show.goto.is_empty()); - show.push_slide_number('9', 0.0); - assert!(show.clear_goto()); - assert!(!show.clear_goto()); - for _ in 0..6 { - show.push_slide_number('9', 1.0); - } - assert_eq!(show.goto.len(), GOTO_DIGITS); - show.push_slide_number('a', 1.0); - assert!(show.goto.chars().all(|c| c.is_ascii_digit())); - } - - #[test] - fn browsed_by_an_individual_stays_in_a_window() { - assert!(!show_fills_screen(false, "browsed")); - assert!(!show_fills_screen(true, "speaker")); - assert!(show_fills_screen(false, "speaker")); - assert!(show_fills_screen(false, "kiosk")); - } - - #[test] - fn the_next_preview_fits_its_column_whatever_the_slide_shape() { - let right = Rect::from_min_size(pos2(700.0, 100.0), vec2(400.0, 600.0)); - let mut s = deckcraft_engine::Session::with_new(); - for (w, h) in [(72, 4032), (4032, 72), (960, 540)] { - s.execute("design.slideSize", &json!({"w": w, "h": h})).unwrap(); - let doc = s.active().unwrap().doc.clone(); - let r = next_rect(right, &doc); - assert!(right.contains_rect(r), "{w}x{h}: {r:?}"); - let aspect = r.height() / r.width(); - assert!((aspect - h as f32 / w as f32).abs() / (h as f32 / w as f32) < 0.01, "{w}x{h}: {r:?}"); - } - // 16:9 still takes the column's full width. - let doc = s.active().unwrap().doc.clone(); - assert_eq!(next_rect(right, &doc).width(), 400.0); - } - - #[test] - fn back_leaves_a_slide_whose_first_animation_is_with_previous() { - // #844: Back on such a slide undid its first step, and the next frame played that step - // again by itself, so Back never reached the slide before. - let mut session = deckcraft_engine::Session::with_new(); - session.execute("slide.new", &json!({})).unwrap(); - let id = session.active().unwrap().doc.slides[1].shapes[0].id; - session - .execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id.0], "start": "withPrevious", "duration": 500})) - .unwrap(); - let app = SlideApp::new(session, crate::Services::default()); - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - harness.run_steps(3); - harness.state_mut().start_show(0, false); - harness.run_steps(2); - harness.key_press(egui::Key::ArrowRight); - // The first step plays by itself and ends (0.5 s; a frame is 0.25 s). - harness.run_steps(8); - let st = &harness.state().show.as_ref().unwrap().state; - assert_eq!((st.slide, st.step, st.playing), (1, 1, false), "on slide 2, fully built"); - for _ in 0..5 { - harness.key_press(egui::Key::ArrowLeft); - harness.run_steps(2); - } - let st = &harness.state().show.as_ref().unwrap().state; - assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing); - } - - /// Click-target state follows show instance, reading/presenter modes, grid, and other layout - /// changes instead of retaining a stale target. - #[test] - fn input_state_tracks_the_modes_that_move_show_click_targets() { - let session = deckcraft_engine::Session::with_new(); - let doc = &session.active().unwrap().doc; - let mut show = Show::new(doc, 0, false, false); - let base = show.input_state(); - assert_eq!(show.input_state(), base); - - show.instance += 1; - assert_ne!(show.input_state(), base, "a new show instance is a new input target"); - show.instance = base.instance; - - show.reading = true; - assert_ne!(show.input_state(), base, "the reading layout moves the slide"); - show.reading = false; - - show.presenter = true; - assert_ne!(show.input_state(), base, "presenter view changes the available area"); - show.presenter = false; - - show.grid = true; - assert_ne!(show.input_state(), base, "See All Slides replaces the slide with thumbnails"); - show.grid = false; - - show.pen = true; - assert_ne!(show.input_state(), base, "the pen changes what a drag means"); - show.pen = false; - - show.laser = true; - assert_ne!(show.input_state(), base, "the laser changes what a drag means"); - show.laser = false; - - show.zoom_arm = true; - assert_ne!(show.input_state(), base, "armed zoom changes what a click means"); - show.zoom_arm = false; - - show.zoom = Some(Zoom::at(0.25, 0.75)); - assert_ne!(show.input_state(), base, "a zoomed slide has a different click mapping"); - show.zoom = None; - - show.blank = Some(Color32::BLACK); - assert_ne!(show.input_state(), base, "a blank screen has no slide click targets"); - } -} +mod tests; diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs new file mode 100644 index 000000000..8c515363a --- /dev/null +++ b/crates/ui-egui/src/show/tests.rs @@ -0,0 +1,995 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; + +/// #1005: shapes stacked above a playing video must be drawn over its frame; the video, what +/// is below it and other videos stay out of that overlay. +#[test] +fn shapes_above_a_playing_video_are_the_overlay() { + let mut session = deckcraft_engine::Session::with_new(); + for _ in 0..3 { + session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, 100.0, 100.0]})).unwrap(); + } + let mut doc = (*session.active().unwrap().doc).clone(); + // Keep only the three inserted rects (a new slide may start with placeholders). + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let keep = slide.shapes.len() - 3; + slide.shapes.drain(..keep); + let ids: Vec = slide.shapes.iter().map(|s| s.id).collect(); + slide.shapes[1].kind = deckcraft_model::ShapeKind::Media(deckcraft_model::MediaClip { video: true, ..Default::default() }); + // Video in the middle: the shape above it is drawn over it. + assert_eq!(below_playing_video(&doc, 0, &[ids[1]]), vec![ids[0], ids[1]]); + // Video on top: nothing above it, the frame is painted last as before. + std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.swap(1, 2); + assert!(below_playing_video(&doc, 0, &[ids[1]]).is_empty()); + // No video playing. + assert!(below_playing_video(&doc, 0, &[]).is_empty()); +} + +/// #968: over a shape a click acts on (a clip with its play trigger, a link) the pointing hand +/// never gave way to the idle hide, so a still pointer stayed on the playing video. +#[test] +fn a_still_cursor_hides_over_a_clickable_shape() { + let mut session = deckcraft_engine::Session::with_new(); + let size = session.active().unwrap().doc.slide_size; + let r = session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + session.execute("insert.hyperlink", &json!({"url": "https://example.org", "ids": [r["id"]]})).unwrap(); + let app = SlideApp::new(session, crate::Services::default()); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.hover_at(pos2(500.0, 350.0)); + harness.run_steps(2); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + harness.run_steps(12); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden"); +} + +/// #1130: the Presenter View's All slides button opens the grid in that window, not on the audience screen. +#[test] +fn presenter_all_slides_does_not_cover_the_audience_screen() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + harness.run_steps(2); + harness.get_by_label("All slides").click(); + harness.run_steps(2); + assert!(harness.state().1.presenter_grid, "the grid opens in the Presenter View"); + assert!(!harness.state().1.grid, "the audience screen keeps showing the slide"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().1.presenter_grid, "Esc closes the Presenter View grid"); + assert!(harness.state().2, "and does not end the show"); + harness.key_press(egui::Key::G); + harness.run_steps(2); + assert!(harness.state().1.presenter_grid, "G in Presenter View opens its own grid"); + assert!(!harness.state().1.grid, "G in Presenter View leaves the audience screen alone"); + harness.key_press(egui::Key::G); + harness.run_steps(2); + assert!(!harness.state().1.presenter_grid, "G again closes it"); +} + +/// A silent mono 8 kHz WAV, so the clip has a real duration to seek in. +fn silent_wav(secs: u32) -> Vec { + let rate = 8_000u32; + let data = vec![0u8; (rate * secs * 2) as usize]; + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b +} + +/// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the +/// bar's seek track, and what `pointer_on_media_bar` said on the last frame. +fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc>) { + let size = vec2(1000.0, 700.0); + let mut session = deckcraft_engine::Session::with_new(); + let id = ShapeId(4000); + session + .edit(|doc, _| { + let media = doc.add_media("clip.wav", "audio/wav", silent_wav(10)).unwrap(); + let (w, h) = (doc.slide_size.width, doc.slide_size.height); + let clip = deckcraft_model::MediaClip { media, video: true, volume: 1.0, duration_ms: 10_000, ..Default::default() }; + let xfrm = deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(deckcraft_model::Shape { id, xfrm: Some(xfrm), kind: deckcraft_model::ShapeKind::Media(clip), ..Default::default() }); + Ok(()) + }) + .unwrap(); + let on_bar = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = on_bar.clone(); + let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING)); + let slide_seen = slide.clone(); + // Presenter view opens an embedded window over the whole screen here, which takes the clicks. + let mut app = SlideApp::new(session, crate::Services::default()); + app.ui.presenter_view = false; + let mut harness = egui_kittest::Harness::builder().with_size(size).with_step_dt(0.25).build_ui_state( + move |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) { + let now = ui.input(|i| i.time); + seen.set(pointer_on_media_bar(ui.ctx(), ui, &d.doc, show, now)); + slide_seen.set(shown_slide_rect(ui, show, &d.doc)); + } + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + let doc = harness.state().session.active().unwrap().doc.clone(); + let bar = slide_media_bars(slide.get(), &doc, 0)[0].3; + let track = pos2(bar.min.x + bar.width() * 0.8, bar.center().y); + (harness, id, track, on_bar) +} + +/// Press and release the primary button at `p`. +fn click_at(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } +} + +/// #967: hovering a clip shows its bar and a click on the bar seeks. After 3 s without moving +/// the bar is gone, and a click where it was acts on the clip itself (play), not on the bar. +#[test] +fn media_controls_hide_when_pointer_is_idle() { + // Awake: the bar is there and a click on its timeline seeks, leaving the clip paused. + let (mut harness, id, track, on_bar) = show_with_clip(); + harness.hover_at(track); + harness.run_steps(2); + assert!(harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + assert!(on_bar.get(), "the bar is under a pointer that just moved"); + click_at(&mut harness, track); + harness.run_steps(2); + let status = harness.state().media.status(id).expect("the click on the bar cued the clip"); + assert!(!harness.state().media.is_playing(id), "a bar click seeks, it does not play"); + assert!(status.position > 1.0, "seeked along the timeline, at {}", status.position); + + // Idle: same spot, no movement for 3 s. + let (mut harness, id, track, on_bar) = show_with_clip(); + harness.hover_at(track); + harness.run_steps(2); + assert!(on_bar.get()); + harness.run_steps(12); + assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + assert!(!on_bar.get(), "the bar is hidden once the pointer is idle"); + click_at(&mut harness, track); + harness.run_steps(2); + assert!(harness.state().media.is_playing(id), "the click reached the clip"); + let at = harness.state().media.status(id).map_or(0.0, |s| s.position); + assert!(at < 5.0, "the click did not seek along the timeline, position {at}"); +} + +/// Two 1×1 frames (black, then white), 100 ms each. +fn two_frame_gif() -> Vec { + let mut b = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff".to_vec(); + for pixel in [b"\x44\x01", b"\x4c\x01"] { + b.extend_from_slice(b"\x21\xf9\x04\x00\x0a\x00\x00\x00"); + b.extend_from_slice(b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02"); + b.extend_from_slice(pixel); + b.push(0); + } + b.push(0x3b); + b +} + +/// A running show on the first of two slides; slide 1 holds a large animated GIF. Returns the +/// harness, the GIF, and its play/pause button's centre. +fn show_with_gif() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2) { + let mut session = deckcraft_engine::Session::with_new(); + // The GIF is on slide 1, the slide the editor has selected, with a slide after it. + session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + session.execute("slide.go", &json!({"index": 1})).unwrap(); + let id = ShapeId(4100); + session + .edit(|doc, _| { + let media = doc.add_media("a.gif", "image/gif", two_frame_gif()).unwrap(); + let (w, h) = (doc.slide_size.width, doc.slide_size.height); + let slide = std::sync::Arc::make_mut(&mut doc.slides[1]); + slide.shapes.push(deckcraft_model::Shape { + id, + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6)), + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING)); + let slide_seen = slide.clone(); + let mut app = SlideApp::new(session, crate::Services::default()); + app.ui.presenter_view = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state( + move |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) { + slide_seen.set(shown_slide_rect(ui, show, &d.doc)); + } + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(1, false); + harness.run_steps(2); + let doc = harness.state().session.active().unwrap().doc.clone(); + let srect = slide.get(); + let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + let rect = doc.slides[1].shapes.last().unwrap().xfrm.unwrap(); + let button = crate::media::gif_button_rect(on_screen(srect, k, &rect)).center(); + (harness, id, button) +} + +/// The GIF's play/pause button is drawn only while the pointer is awake, so a click on its +/// spot after 2.5 s without movement advances the show instead of pausing the GIF. +#[test] +fn gif_button_click_needs_an_awake_pointer() { + let (mut harness, id, button) = show_with_gif(); + harness.hover_at(button); + harness.run_steps(2); + click_at(&mut harness, button); + harness.run_steps(2); + assert!(harness.state().session.gif_paused.contains(&id), "an awake pointer's click pauses the GIF"); + assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1); + + let (mut harness, id, button) = show_with_gif(); + harness.hover_at(button); + harness.run_steps(2); + harness.run_steps(12); + assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + click_at(&mut harness, button); + harness.run_steps(2); + assert!(!harness.state().session.gif_paused.contains(&id), "the hidden button is not clickable"); + assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 2, "the click advanced the show"); +} + +/// Send real egui key events to the presenter UI, without the audience window's handler. +#[test] +fn presenter_navigation_uses_its_own_keyboard_events() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + harness.run_steps(2); + for (key, slide) in [ + (egui::Key::ArrowRight, 1), + (egui::Key::ArrowLeft, 0), + (egui::Key::Space, 1), + (egui::Key::PageDown, 2), + (egui::Key::PageUp, 1), + (egui::Key::Home, 0), + (egui::Key::End, 2), + ] { + harness.key_press(key); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, slide, "presenter key {key:?}"); + } + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().1.ended); + harness.key_press(egui::Key::ArrowLeft); + harness.run_steps(2); + assert!(!harness.state().1.ended); + assert_eq!(harness.state().1.state.slide, 2, "back from the end screen restores the last slide"); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().1.ended); + harness.get_by_label("◀ Previous").click(); + harness.run_steps(2); + assert!(!harness.state().1.ended, "the presenter Previous button also leaves the end screen"); + harness.key_press(egui::Key::Home); + harness.run_steps(2); + harness.get_by_label("Next ▶").click(); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, 1); + harness.key_press(egui::Key::Space); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, 2); + assert!(!harness.state().1.ended, "Space must not also activate the focused Next button"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().2, "Escape from Presenter View ends the show"); +} + +#[test] +fn slideshow_navigation_advances_once_with_presenter_view() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let mut app = SlideApp::new(session, crate::Services::default()); + app.ui.presenter_view = true; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1, "one key advances one slide"); + harness.key_press(egui::Key::End); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().show.as_ref().unwrap().ended); + assert!(harness.query_by_label("End Show").is_some(), "the presenter remains available at the end screen"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.state().show.is_none()); +} + +#[test] +fn the_prefetched_frame_is_the_frame_shown_on_entry() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({"title": "Second"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let size = (96, 54); + let p = start_prefetch(&doc, 1, size).expect("prefetch starts"); + assert_eq!(p.key, Key { slide: 1, step: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: vec![], gif: 0, plays: vec![] }); + let got = p.rx.recv().expect("the frame arrives"); + let want = render_frame(&doc, 1, ShowState::timeline(&doc, 1), 0, 0.0, size, vec![], &[], 0, &[], &[], None); + assert_eq!(got.size, want.size); + assert_eq!(got.pixels, want.pixels); +} + +/// A glowing full-slide picture in a layout made every animation frame re-render (and re-blur) +/// the backdrop: 2–3 frames a second. Frames now draw the slide's shapes over the kept +/// backdrop, and must look the same as a whole-slide render. +#[test] +fn an_animation_frame_over_the_kept_backdrop_matches_a_full_render() { + let mut s = deckcraft_engine::Session::with_new(); + let r = s.execute("shape.insert", &json!({"preset": "star5", "rect": [100.0, 100.0, 300.0, 200.0]})).unwrap(); + s.execute("animation.set", &json!({"effect": "fade", "ids": [r["id"]]})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + // A glowing master graphic under the animated star. + let mut deco = doc.slides[0].shapes.last().unwrap().clone(); + deco.id = ShapeId(9000); + deco.xfrm = deco.xfrm.map(|x| deckcraft_geom::Xfrm { x: x.x - 60.0, y: x.y + 40.0, ..x }); + deco.effects = Some(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { + color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 120, 255)), + radius: 12.0, + }), + ..Default::default() + }); + std::sync::Arc::make_mut(&mut doc.masters[0]).shapes.push(deco); + let doc = std::sync::Arc::new(doc); + let size = (480, 270); + let key = BackKey { slide: 0, size, doc: std::sync::Arc::as_ptr(&doc) as usize }; + let mut show = Show::new(&doc, 0, false, false); + show.prefetch_backdrop(&doc, key); + let back = show.backdrop(&doc, key); + let tl = ShowState::timeline(&doc, 0); + assert!(!tl.is_empty()); + // Half-way through the fade, the star half see-through over the glow. + let fast = render_frame(&doc, 0, tl.clone(), 0, 0.25, size, vec![], &[], 0, &[], &[], Some(&back)); + let full = render_frame(&doc, 0, tl, 0, 0.25, size, vec![], &[], 0, &[], &[], None); + assert_eq!(fast.size, full.size); + let worst = + fast.pixels.iter().zip(&full.pixels).map(|(a, b)| a.to_array().iter().zip(b.to_array()).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)).max(); + assert!(worst.is_some_and(|d| d <= 2), "frames differ by up to {worst:?}"); + // The backdrop is kept, not rendered again. + assert!(std::sync::Arc::ptr_eq(&back, &show.backdrop(&doc, key))); + // Another document under the same key (an edit whose copy reused the old address) gets + // its own backdrop. + let edited = std::sync::Arc::new((*doc).clone()); + assert!(!std::sync::Arc::ptr_eq(&back, &show.backdrop(&edited, key))); +} + +#[test] +fn a_transition_keeps_the_old_slide_in_its_own_texture() { + // #33: the old slide's handle was cloned, so the new slide was rendered into the same + // texture and every transition blended the new slide with itself. + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); + let mut show = Show::new(&doc, 0, false, false); + show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone())); + assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); + let tr = show.trans.as_ref().expect("the fade plays"); + assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id())); + assert_eq!(tr.from, Some(0)); + assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); +} + +#[test] +fn media_in_the_click_sequence_plays_with_its_step() { + // #116: media set to play in the click sequence never started; only a click on it did. + let s = deckcraft_engine::Session::with_new(); + let mut doc = s.active().unwrap().doc.clone(); + let id = doc.slides[0].shapes[0].id; + let play = |delay_ms| deckcraft_model::Animation { + shape: id, + class: deckcraft_model::AnimClass::Media, + effect: "play".into(), + duration_ms: 5000, + delay_ms, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut doc).slides[0]).animations = vec![play(0), play(2000)]; + let mut show = Show::new(&doc, 0, false, false); + assert!(show.apply(ShowAction::Slide(0), &doc, 0.0)); + // Next: the first step plays and sets off its Play once. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(0)); + assert!(show.apply(a, &doc, 1.0)); + show.cue_media(&doc, 0, 0.0); + show.cue_media(&doc, 0, 0.5); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // The step doesn't wait for the video: the step clock ends it at once. + assert_eq!(show.timeline(&doc).step_duration(0), 0.0); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 1.0)); + assert!(show.media_cues.is_empty(), "set off once"); + // Next again, before the second Play's delay: cutting the step short still sets it off. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(1)); + assert!(show.apply(a, &doc, 2.0)); + show.cue_media(&doc, 1, 0.5); + assert!(show.media_cues.is_empty()); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 2.6)); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // Going back doesn't set anything off. + let a = show.state.prev(&doc); + assert!(show.apply(a, &doc, 3.0)); + show.cue_media(&doc, 1, 9.0); + assert!(show.media_cues.is_empty()); +} + +#[test] +fn the_laser_and_the_pen_are_not_on_together() { + let doc = deckcraft_model::Presentation::default(); + let mut show = Show::new(&doc, 0, false, false); + show.set_laser(true); + assert!(show.laser && !show.pen); + show.set_pen(true); + assert!(show.pen && !show.laser); + show.set_laser(true); + show.arrow(); + assert!(!show.laser && !show.pen); + show.set_laser(false); + assert!(!show.laser); +} + +fn deck_with_first_animation(start: deckcraft_model::anim::AnimStart) -> Presentation { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let mut doc = Presentation::clone(&s.active().unwrap().doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[1]); + let shape = slide.shapes[0].id; + slide.animations = vec![deckcraft_model::anim::Animation { shape, start, duration_ms: 3000, ..Default::default() }]; + doc +} + +#[test] +fn a_first_animation_after_or_with_previous_plays_when_the_slide_appears() { + // #631: it was started twice and the second start finished it, so the slide appeared + // fully built. + use deckcraft_model::anim::AnimStart; + for start in [AnimStart::AfterPrevious, AnimStart::WithPrevious] { + let doc = deck_with_first_animation(start); + // Starting the show on the slide. + let mut show = Show::new(&doc, 1, false, false); + show.after_enter(&doc, 5.0); + assert_eq!((show.state.step, show.state.playing), (0, true), "{start:?} from the start"); + assert_eq!(show.step_start, 5.0); + // Arriving from the slide before. + let mut show = Show::new(&doc, 0, false, false); + let a = show.state.next(&doc); + assert!(matches!(a, ShowAction::Slide(1))); + assert!(show.apply(a, &doc, 7.0)); + assert_eq!((show.state.step, show.state.playing), (0, true), "{start:?} on arrival"); + } + // On Click still waits for the click. + let doc = deck_with_first_animation(AnimStart::OnClick); + let mut show = Show::new(&doc, 1, false, false); + show.after_enter(&doc, 5.0); + assert_eq!((show.state.step, show.state.playing), (0, false)); + assert!(matches!(show.state.next(&doc), ShowAction::PlayStep(0))); +} + +#[test] +fn zoom_stays_on_the_slide_and_the_grid_hits_a_thumbnail() { + let uv = zoom_uv(0.5, 0.5, 2.0); + assert!((uv.min.x - 0.25).abs() < 1e-4 && (uv.min.y - 0.25).abs() < 1e-4); + assert!((uv.width() - 0.5).abs() < 1e-4); + let corner = zoom_uv(0.0, 0.0, 2.0); + assert_eq!(corner.min, pos2(0.0, 0.0)); + assert!(corner.max.x <= 1.0 && corner.max.y <= 1.0); + let full = Rect::from_min_size(Pos2::ZERO, vec2(1728.0, 1084.0)); + let cells = see_all_cells(full, 7, 16.0 / 9.0); + assert_eq!(cells.len(), 7); + for (i, a) in cells.iter().enumerate() { + assert!(full.contains_rect(*a), "cell {i} leaves the window"); + for b in cells.iter().skip(i + 1) { + let overlap = a.intersect(*b); + assert!(overlap.width() < 1.0 || overlap.height() < 1.0, "thumbnails overlap {a:?} {b:?}"); + } + } + let hit = see_all_hit(full, 7, 16.0 / 9.0, cells[3].center()); + assert_eq!(hit, Some(3)); + assert_eq!(see_all_hit(full, 7, 16.0 / 9.0, pos2(2.0, 2.0)), None); + let srect = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 100.0)); + assert_eq!(norm_on_slide(srect, unit_uv(), pos2(110.0, 70.0)), Some((0.5, 0.5))); + let zoomed = zoom_uv(0.5, 0.5, 2.0); + let back = norm_on_slide(srect, zoomed, pos2(110.0, 70.0)).expect("on the slide"); + assert!((back.0 - 0.5).abs() < 1e-3 && (back.1 - 0.5).abs() < 1e-3); +} + +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 +} + +/// #1286: Ctrl+wheel zooms around the pointer, four notches per doubling, up to 4× (the most +/// PowerPoint's magnifier goes); zooming back to the whole slide leaves the zoom. +#[test] +fn wheel_zoom_keeps_the_point_under_the_pointer_and_stops_at_4x() { + let doc = deckcraft_model::Presentation::default(); + let mut show = Show::new(&doc, 0, false, false); + // A 16:9 slide, not at the origin, and a pointer off its centre. + let srect = Rect::from_min_size(pos2(40.0, 30.0), vec2(320.0, 180.0)); + let p = pos2(40.0 + 320.0 * 0.2, 30.0 + 180.0 * 0.3); + show.wheel_zoom(srect, p, 4.0); + let (_, _, scale) = show.zoom_view().expect("zoomed in"); + assert!(near(scale, 2.0), "four notches double, got {scale}"); + // The window keeps the slide's shape: half the width and half the height of the slide. + let uv = show.zoom_uv(); + assert!(near(uv.width(), 0.5) && near(uv.height(), 0.5), "{uv:?}"); + assert!(near(uv.min.x, 0.2 - 0.5 * 0.2) && near(uv.min.y, 0.3 - 0.5 * 0.3), "{uv:?}"); + let under = norm_on_slide(srect, uv, p).expect("on the slide"); + assert!(near(under.0, 0.2) && near(under.1, 0.3), "the point under the pointer moved to {under:?}"); + + // Again from the zoomed view: the same slide point stays under the pointer. + show.wheel_zoom(srect, p, 2.0); + let (_, _, scale) = show.zoom_view().expect("zoomed in"); + assert!(near(scale, 2f32.powf(1.5)), "{scale}"); + let under = norm_on_slide(srect, show.zoom_uv(), p).expect("on the slide"); + assert!(near(under.0, 0.2) && near(under.1, 0.3), "{under:?}"); + + show.wheel_zoom(srect, p, 40.0); + let (_, _, scale) = show.zoom_view().expect("zoomed in"); + assert!(near(scale, 4.0), "capped at 4×, got {scale}"); + assert!(near(show.zoom_uv().width(), 0.25)); + + show.wheel_zoom(srect, p, -7.0); + assert!(show.zoom_view().is_some(), "one notch short of the whole slide is still zoomed"); + show.wheel_zoom(srect, p, -1.0); + assert!(show.zoom_view().is_none(), "back to the whole slide leaves the zoom"); + assert_eq!(show.zoom_uv(), unit_uv()); + show.wheel_zoom(srect, p, -3.0); + assert!(show.zoom_view().is_none(), "zooming out of the whole slide does nothing"); + + // Off the slide (the letterbox) the wheel does not zoom. + show.wheel_zoom(srect, pos2(10.0, 10.0), 4.0); + assert!(show.zoom_view().is_none()); + // An armed click-zoom gives way to the wheel. + show.zoom_arm = true; + show.wheel_zoom(srect, p, 4.0); + assert!(!show.zoom_arm && show.zoom_view().is_some()); +} + +#[test] +fn wheel_zoom_reads_only_the_wheel_with_ctrl_or_cmd() { + let wheel = |unit, y: f32, modifiers| egui::Event::MouseWheel { + unit, + delta: vec2(0.0, y), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers, + }; + use egui::MouseWheelUnit::{Line, Point}; + assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::CTRL)], 40.0), Some(1.0)); + assert_eq!(wheel_zoom_lines(&[wheel(Line, -2.0, egui::Modifiers::COMMAND)], 40.0), Some(-2.0)); + assert_eq!(wheel_zoom_lines(&[wheel(Point, 20.0, egui::Modifiers::CTRL), wheel(Point, 20.0, egui::Modifiers::CTRL)], 40.0), Some(1.0)); + assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::NONE)], 40.0), None, "a plain wheel turns the page"); + assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::SHIFT)], 40.0), None); + assert_eq!(wheel_zoom_lines(&[egui::Event::Zoom(2.0)], 40.0), None, "a pinch is not a show gesture"); + assert_eq!(wheel_zoom_lines(&[wheel(Line, f32::NAN, egui::Modifiers::CTRL)], 40.0), Some(0.0)); +} + +/// A running show on the first of three slides, with presenter view off. +fn show_of_three() -> egui_kittest::Harness<'static, SlideApp> { + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let mut app = SlideApp::new(session, crate::Services::default()); + app.ui.presenter_view = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness +} + +fn show_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, at: Pos2, y: f32, modifiers: egui::Modifiers) { + harness.input_mut().events.push(egui::Event::PointerMoved(at)); + harness.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, y), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers, + }); + harness.run_steps(40); +} + +/// #1286: in the show, Ctrl+wheel (Cmd+wheel on a Mac) zooms the slide and does not turn the +/// page; the plain wheel still turns it. +#[test] +fn ctrl_wheel_zooms_the_show_and_the_plain_wheel_turns_the_page() { + for held in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { + let mut h = show_of_three(); + let centre = pos2(500.0, 350.0); + show_wheel(&mut h, centre, 4.0, held); + let show = h.state().show.as_ref().expect("still showing"); + assert_eq!(show.state.slide, 0, "{held:?}: the zoom wheel does not turn the page"); + let (_, _, scale) = show.zoom_view().expect("zoomed in"); + assert!(near(scale, 2.0), "{held:?}: {scale}"); + + show_wheel(&mut h, centre, -4.0, held); + let show = h.state().show.as_ref().expect("still showing"); + assert_eq!(show.state.slide, 0, "{held:?}: zooming out does not go back a page either"); + assert!(show.zoom_view().is_none(), "{held:?}: back to the whole slide"); + + show_wheel(&mut h, centre, -3.0, egui::Modifiers::NONE); + assert_eq!(h.state().show.as_ref().expect("still showing").state.slide, 1, "the plain wheel turns the page"); + } +} + +#[test] +fn escape_only_closes_the_grid_of_its_own_window() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + show.presenter_grid = true; + assert!(!show.back_out(false), "audience Esc leaves the show, the Presenter View grid stays"); + assert!(show.presenter_grid); + assert!(show.back_out(true)); + assert!(!show.presenter_grid); + show.grid = true; + assert!(!show.back_out(true), "Presenter View Esc does not close the audience grid"); + assert!(show.grid); + assert!(show.back_out(false)); + assert!(!show.grid); +} + +#[test] +fn escape_leaves_the_grid_then_the_zoom_and_blank_toggles() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + show.grid = true; + show.zoom = Some(Zoom::at(0.2, 0.3)); + assert!(show.back_out(false)); + assert!(!show.grid); + assert!(show.zoom.is_some(), "closing the grid leaves the zoom"); + assert!(show.back_out(false)); + assert!(show.zoom.is_none()); + assert!(!show.back_out(false)); + show.zoom_key(); + assert!(show.zoom_arm); + show.zoom_key(); + assert!(!show.zoom_arm); + toggle_blank(&mut show, Color32::BLACK); + assert_eq!(show.blank_name(), Some("black")); + toggle_blank(&mut show, Color32::WHITE); + assert_eq!(show.blank_name(), Some("white")); + toggle_blank(&mut show, Color32::WHITE); + assert_eq!(show.blank_name(), None); + show.zoom = Some(Zoom::at(0.4, 0.6)); + assert!(show.apply(ShowAction::Slide(1), &doc, 1.0)); + assert!(show.zoom.is_none(), "a new slide is shown whole"); +} + +/// #1128: the presenter drew the notes as plain text, so bullets, numbers and bold were lost +/// although the notes pane shows them; the notes stay selectable. +#[test] +fn presenter_notes_keep_bullets_numbers_and_bold() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |text: &str, bullet: Bullet| Paragraph { + level: 1, + props: ParaProps { bullet: Some(bullet), ..Default::default() }, + runs: vec![Run::new(text)], + ..Default::default() + }; + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Opening", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, + point("Welcome", Bullet::Char { char: "•".into() }), + point("Agenda", num()), + point("Demo", num()), + ], + ..Default::default() + }; + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut sense = None; + let mut out = ctx.run_ui(Default::default(), |ui| sense = Some(presenter_notes(ui, ¬es, 24.0).sense)); + out.textures_delta.clear(); + assert!(sense.unwrap().senses_drag(), "the notes can be selected, as the plain label could"); + let mut texts = vec![]; + let mut stack: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(v) => stack.extend(v), + egui::Shape::Text(t) => texts.push(t.galley), + _ => {} + } + } + let painted: Vec<&str> = texts.iter().map(|g| g.job.text.as_str()).collect(); + for marker in ["•", "1.", "2."] { + assert!(painted.contains(&marker), "{marker} is drawn: {painted:?}"); + } + let notes_galley = texts.iter().find(|g| g.job.text.contains("Agenda")).expect("the notes are drawn"); + let font_of = |s: &str| { + let b = notes_galley.job.text.find(s).unwrap(); + notes_galley.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone() + }; + assert_eq!(font_of("Opening"), theme::bold(24.0), "bold runs stay bold at the pane's size"); + assert_eq!(font_of("Agenda"), theme::font(24.0)); +} + +/// Hidden logic keeps playing from wall time even though `run_logic` leaves egui's input +/// time at the last visible frame. A visible pass re-anchors instead of shifting a pause. +#[test] +fn hidden_logic_advances_the_show_and_visible_frames_reanchor() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + show.started = true; + show.step_start = 10.0; + show.slide_start = 10.0; + show.step_end = 10.0; + show.show_start = 10.0; + show.last_move = 10.0; + show.timer = PresenterTimer::start(10.0); + let t0 = web_time::Instant::now(); + show.observe_visible_frame(10.0, t0); + assert_eq!(show.hidden_logic_time(10.0, t0), 10.0, "the first hidden tick starts at the last visible time"); + let hidden = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(2)); + assert!((hidden - 12.0).abs() < 1e-9, "hidden logic advances the show time: {hidden}"); + assert!((show.presenter_elapsed(hidden) - 2.0).abs() < 1e-9, "presenter time keeps running"); + assert!((show.clock_snapshot().step_start - 10.0).abs() < 1e-9, "the clock origin is not shifted"); + let later = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(5)); + assert!((later - 15.0).abs() < 1e-9, "repeated hidden ticks do not stack a pause: {later}"); + show.observe_visible_frame(20.0, t0 + std::time::Duration::from_secs(10)); + assert_eq!(show.hidden_logic_time(20.0, t0 + std::time::Duration::from_secs(10)), 20.0, "a visible frame re-anchors"); +} + +#[test] +fn presenter_timer_pauses_resets_and_notes_size_clamps() { + let mut t = PresenterTimer::start(10.0); + assert!((t.elapsed(12.5) - 2.5).abs() < 1e-9); + t.toggle(15.0); + assert!(t.paused()); + assert!((t.elapsed(100.0) - 5.0).abs() < 1e-9); + t.reset(100.0); + assert!(t.paused()); + assert_eq!(t.elapsed(200.0), 0.0); + t.toggle(200.0); + assert!(!t.paused()); + assert!((t.elapsed(203.0) - 3.0).abs() < 1e-9); + t.reset(203.0); + assert!(!t.paused()); + assert!((t.elapsed(204.0) - 1.0).abs() < 1e-9); + let bad = PresenterTimer::start(f64::NAN); + assert_eq!(bad.elapsed(f64::INFINITY), 0.0); + assert_eq!(format_elapsed(3661.4), "01:01:01"); + assert_eq!(format_elapsed(-1.0), "00:00:00"); + assert_eq!(format_elapsed(f64::NAN), "00:00:00"); + let clock = wall_clock(); + assert!(clock == "--:--" || (clock.len() == 5 && clock.as_bytes().get(2) == Some(&b':'))); + + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + assert_eq!(show.notes_px, NOTES_DEFAULT); + show.nudge_notes(NOTES_STEP); + assert_eq!(show.notes_px, 22.0); + show.nudge_notes(1000.0); + assert_eq!(show.notes_px, NOTES_MAX); + show.nudge_notes(-1000.0); + assert_eq!(show.notes_px, NOTES_MIN); + show.nudge_notes(f32::NAN); + assert_eq!(show.notes_px, NOTES_MIN); + let v = show.showing_value(1, 16.0 / 9.0, Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), 0.0); + assert_eq!(v["presenterPaused"], json!(false)); + assert_eq!(v["notesPx"], json!(NOTES_MIN)); +} + +#[test] +fn a_typed_slide_number_jumps_and_then_expires() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, false); + show.push_slide_number('1', 10.0); + show.push_slide_number('2', 10.2); + assert_eq!(show.goto, "12"); + assert_eq!(show.take_goto(), Some(11)); + assert!(show.goto.is_empty()); + show.push_slide_number('0', 1.0); + assert_eq!(show.take_goto(), None, "slide 0 is not a slide"); + show.push_slide_number('4', 5.0); + show.expire_goto(7.0); + assert_eq!(show.goto, "4"); + show.expire_goto(8.1); + assert!(show.goto.is_empty()); + show.push_slide_number('9', 0.0); + assert!(show.clear_goto()); + assert!(!show.clear_goto()); + for _ in 0..6 { + show.push_slide_number('9', 1.0); + } + assert_eq!(show.goto.len(), GOTO_DIGITS); + show.push_slide_number('a', 1.0); + assert!(show.goto.chars().all(|c| c.is_ascii_digit())); +} + +#[test] +fn browsed_by_an_individual_stays_in_a_window() { + assert!(!show_fills_screen(false, "browsed")); + assert!(!show_fills_screen(true, "speaker")); + assert!(show_fills_screen(false, "speaker")); + assert!(show_fills_screen(false, "kiosk")); +} + +#[test] +fn the_next_preview_fits_its_column_whatever_the_slide_shape() { + let right = Rect::from_min_size(pos2(700.0, 100.0), vec2(400.0, 600.0)); + let mut s = deckcraft_engine::Session::with_new(); + for (w, h) in [(72, 4032), (4032, 72), (960, 540)] { + s.execute("design.slideSize", &json!({"w": w, "h": h})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let r = next_rect(right, &doc); + assert!(right.contains_rect(r), "{w}x{h}: {r:?}"); + let aspect = r.height() / r.width(); + assert!((aspect - h as f32 / w as f32).abs() / (h as f32 / w as f32) < 0.01, "{w}x{h}: {r:?}"); + } + // 16:9 still takes the column's full width. + let doc = s.active().unwrap().doc.clone(); + assert_eq!(next_rect(right, &doc).width(), 400.0); +} + +#[test] +fn back_leaves_a_slide_whose_first_animation_is_with_previous() { + // #844: Back on such a slide undid its first step, and the next frame played that step + // again by itself, so Back never reached the slide before. + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let id = session.active().unwrap().doc.slides[1].shapes[0].id; + session + .execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id.0], "start": "withPrevious", "duration": 500})) + .unwrap(); + let app = SlideApp::new(session, crate::Services::default()); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + // The first step plays by itself and ends (0.5 s; a frame is 0.25 s). + harness.run_steps(8); + let st = &harness.state().show.as_ref().unwrap().state; + assert_eq!((st.slide, st.step, st.playing), (1, 1, false), "on slide 2, fully built"); + for _ in 0..5 { + harness.key_press(egui::Key::ArrowLeft); + harness.run_steps(2); + } + let st = &harness.state().show.as_ref().unwrap().state; + assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing); +} + +/// Click-target state follows show instance, reading/presenter modes, grid, and other layout +/// changes instead of retaining a stale target. +#[test] +fn input_state_tracks_the_modes_that_move_show_click_targets() { + let session = deckcraft_engine::Session::with_new(); + let doc = &session.active().unwrap().doc; + let mut show = Show::new(doc, 0, false, false); + let base = show.input_state(); + assert_eq!(show.input_state(), base); + + show.instance += 1; + assert_ne!(show.input_state(), base, "a new show instance is a new input target"); + show.instance = base.instance; + + show.reading = true; + assert_ne!(show.input_state(), base, "the reading layout moves the slide"); + show.reading = false; + + show.presenter = true; + assert_ne!(show.input_state(), base, "presenter view changes the available area"); + show.presenter = false; + + show.grid = true; + assert_ne!(show.input_state(), base, "See All Slides replaces the slide with thumbnails"); + show.grid = false; + + show.pen = true; + assert_ne!(show.input_state(), base, "the pen changes what a drag means"); + show.pen = false; + + show.laser = true; + assert_ne!(show.input_state(), base, "the laser changes what a drag means"); + show.laser = false; + + show.zoom_arm = true; + assert_ne!(show.input_state(), base, "armed zoom changes what a click means"); + show.zoom_arm = false; + + show.zoom = Some(Zoom::at(0.25, 0.75)); + assert_ne!(show.input_state(), base, "a zoomed slide has a different click mapping"); + show.zoom = None; + + show.blank = Some(Color32::BLACK); + assert_ne!(show.input_state(), base, "a blank screen has no slide click targets"); +} From 90a3ffe766a2b8c78d97b45c8f1d93d54ac20042 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:30 +0000 Subject: [PATCH 11/90] refactor(ui-egui): move canvas tests to canvas/tests.rs --- crates/ui-egui/src/canvas.rs | 578 +---------------------------- crates/ui-egui/src/canvas/tests.rs | 569 ++++++++++++++++++++++++++++ 2 files changed, 570 insertions(+), 577 deletions(-) create mode 100644 crates/ui-egui/src/canvas/tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0ae83b696..c4d41d9db 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1611,583 +1611,7 @@ fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::Shape } #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use std::sync::Arc; - - use deckcraft_engine::Session; - use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; - use egui_kittest::Harness; - use egui_kittest::kittest::{NodeT, Queryable}; - - use super::*; - use crate::{Services, SlideApp}; - - /// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's - /// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old - /// fixed handle-to-cursor mapping showed NW-SE there. - #[test] - fn resize_cursor_tracks_rotation() { - let mut s = Session::with_new(); - // A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape - // rotation cleanly moves the cursor to the next of the four resize directions. - let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"] - .as_u64() - .unwrap() as u32; - let sid = deckcraft_model::ShapeId(id); - // Unrotated: the NW handle (0) drags the NW-SE diagonal. - assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe); - assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe); - assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw); - assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw); - assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical); - assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal); - // 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical. - { - let doc = s.doc_mut().unwrap(); - let pres = Arc::make_mut(&mut doc.doc); - let slide = Arc::make_mut(&mut pres.slides[0]); - if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid) - && let Some(x) = sh.xfrm.as_mut() - { - x.rot = 45.0; - } - } - assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically"); - assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally"); - // 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW. - { - let doc = s.doc_mut().unwrap(); - let pres = Arc::make_mut(&mut doc.doc); - let slide = Arc::make_mut(&mut pres.slides[0]); - if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid) - && let Some(x) = sh.xfrm.as_mut() - { - x.rot = 90.0; - } - } - assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW"); - assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE"); - assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal"); - } - - /// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The - /// corner's direction is the diagonal, not the line to the box's centre, so a wide - /// rectangle's NW corner still drags along the NW-SE diagonal. - #[test] - fn a_wide_shape_keeps_its_corner_cursors_diagonal() { - let mut s = Session::with_new(); - let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"] - .as_u64() - .unwrap() as u32; - let sid = deckcraft_model::ShapeId(id); - assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE"); - assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE"); - assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW"); - assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW"); - // The edges keep their own cursors. - assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically"); - assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally"); - } - - /// #745: a placeholder that inherits its box from the layout still shows a resize cursor. - /// Its handles are drawn from the inherited box, so the cursor follows that one. - #[test] - fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() { - let s = Session::with_new(); - let st = s.doc().unwrap(); - let ph = st - .current_slide() - .unwrap() - .shapes - .iter() - .find(|sh| sh.xfrm.is_none() && sh.ph.is_some()) - .expect("the new slide has a placeholder without a box of its own") - .id; - assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE"); - assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW"); - assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically"); - } - - fn harness() -> Harness<'static, SlideApp> { - let mut h = 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()), - ); - h.run_steps(3); - h - } - - /// The slide point (in slide points) under screen position `p`. - fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 { - let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); - pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) - } - - /// Test helper: inject a platform-command-modified line wheel event and run thirty harness - /// steps. - fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { - let wheel = Event::MouseWheel { - unit: MouseWheelUnit::Line, - delta: vec2(0.0, lines), - phase: TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers: Modifiers::COMMAND, - }; - h.event(wheel); - // egui spreads a wheel notch over several frames. - h.run_steps(30); - } - - /// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel - /// grid (#184). - fn assert_near(a: Pos2, b: Pos2, what: &str) { - assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}"); - } - - /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. - #[test] - fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() { - let mut h = harness(); - let fit = h.state().canvas_scale; - let r = h.state().slide_rect.unwrap(); - let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7); - h.event(Event::PointerMoved(p)); - h.run_steps(1); - let under = slide_point(&h, p); - - h.event(Event::Zoom(2.0)); - h.run_steps(2); - assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3); - assert_near(under, slide_point(&h, p), "pinch in"); - - ctrl_wheel(&mut h, 1.0); - assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in"); - assert_near(under, slide_point(&h, p), "wheel in"); - - ctrl_wheel(&mut h, -1.0); - ctrl_wheel(&mut h, -1.0); - assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out"); - assert_near(under, slide_point(&h, p), "wheel out"); - - h.event(Event::Zoom(0.8)); - h.run_steps(2); - assert_near(under, slide_point(&h, p), "pinch out"); - - // Zoomed out below the window size, the slide is centred again. - h.event(Event::Zoom(0.25)); - h.run_steps(2); - assert!(h.state().canvas_scale < fit); - let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); - assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}"); - } - - /// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is. - #[test] - fn zoom_commands_keep_the_middle_of_the_view() { - let mut h = harness(); - let c = h.state().canvas_rect.unwrap().center(); - let mid = slide_point(&h, c); - for _ in 0..4 { - h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap(); - h.run_steps(2); - assert_near(mid, slide_point(&h, c), "zoom in"); - } - h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap(); - h.run_steps(2); - assert_near(mid, slide_point(&h, c), "zoom out"); - } - - /// Drags with the primary button from `a` to `b`; `during` runs after every move. - fn drag_with(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2, mut during: impl FnMut(&SlideApp)) { - h.hover_at(a); - h.run_steps(1); - h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - for i in 1..=8 { - h.event(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); - h.run_steps(1); - during(h.state()); - } - h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - } - - /// #1157: a slide zoomed past the window has a scroll bar on each axis that overflows, and - /// dragging its thumb pans the slide, not a marquee on the canvas. - #[test] - fn scroll_bars_pan_a_zoomed_slide() { - let mut h = harness(); - let fit = h.state().canvas_scale; - h.state_mut().ui.zoom = Some(fit * 3.0); - h.run_steps(3); - let canvas = h.state().canvas_rect.unwrap(); - let before = h.state().slide_rect.unwrap(); - let no_marquee = - |app: &SlideApp| assert!(app.session.tool.marquee.is_none() && !app.session.tool.dragging(), "the bar drag reached the slide"); - - // Zoomed about the middle, so each thumb sits in the middle of its track. - let bottom = pos2(canvas.center().x, canvas.max.y - 4.0); - drag_with(&mut h, bottom, bottom + vec2(150.0, 0.0), no_marquee); - let after = h.state().slide_rect.unwrap(); - assert!(before.min.x - after.min.x > 150.0, "the horizontal thumb pans right: {before:?} -> {after:?}"); - assert!((before.min.y - after.min.y).abs() < 1.0, "only across: {before:?} -> {after:?}"); - - let right = pos2(canvas.max.x - 4.0, canvas.center().y); - drag_with(&mut h, right, right + vec2(0.0, 100.0), no_marquee); - let down = h.state().slide_rect.unwrap(); - assert!(after.min.y - down.min.y > 100.0, "the vertical thumb pans down: {after:?} -> {down:?}"); - assert!((after.min.x - down.min.x).abs() < 1.0, "only down: {after:?} -> {down:?}"); - - // A click on the track beside the thumb pages back toward it. - let left = pos2(canvas.min.x + 20.0, canvas.max.y - 4.0); - h.hover_at(left); - h.run_steps(1); - h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - let paged = h.state().slide_rect.unwrap(); - assert!(paged.min.x - down.min.x > 100.0, "a track click pages left: {down:?} -> {paged:?}"); - assert!(h.state().session.tool.marquee.is_none()); - - // The ends of the tracks reach the slide's far edges. - drag_with(&mut h, right, right + vec2(0.0, 2000.0), |_| {}); - let end = h.state().slide_rect.unwrap(); - assert!((end.max.y - (canvas.max.y - 28.0)).abs() < 2.0, "the bottom of the slide comes into view: {end:?} in {canvas:?}"); - } - - /// A press on the thumb that ends just past its end is a nudge, not a page: the track click - /// is decided where the button went down, not where it came up. - #[test] - fn a_short_press_on_the_thumb_does_not_page() { - let mut h = harness(); - let fit = h.state().canvas_scale; - h.state_mut().ui.zoom = Some(fit * 3.0); - h.run_steps(3); - let (canvas, before) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); - let overflow = vec2((before.width() + 56.0 - canvas.width()).max(0.0), (before.height() + 56.0 - canvas.height()).max(0.0)); - let offset = canvas.min + vec2(28.0, 28.0) - before.min; - let (_, thumb, _) = scroll_bar(canvas, 0.0, overflow, offset, 0).unwrap(); - let (a, b) = (pos2(thumb.max.x - 3.0, canvas.max.y - 4.0), pos2(thumb.max.x + 3.0, canvas.max.y - 4.0)); - h.hover_at(a); - h.run_steps(1); - h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(b)); - h.run_steps(1); - h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - let after = h.state().slide_rect.unwrap(); - let moved = before.min.x - after.min.x; - assert!((0.0..40.0).contains(&moved), "a 6 pt nudge of the thumb, not a page: {before:?} -> {after:?}"); - } - - /// Only the primary button drags a thumb: a right or middle drag after a left one leaves the - /// slide where it is. - #[test] - fn only_the_primary_button_drags_a_thumb() { - let mut h = harness(); - let fit = h.state().canvas_scale; - h.state_mut().ui.zoom = Some(fit * 3.0); - h.run_steps(3); - let canvas = h.state().canvas_rect.unwrap(); - let bottom = pos2(canvas.center().x, canvas.max.y - 4.0); - drag_with(&mut h, bottom, bottom + vec2(40.0, 0.0), |_| {}); - let before = h.state().slide_rect.unwrap(); - for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] { - let a = pos2(canvas.center().x + 40.0, canvas.max.y - 4.0); - h.hover_at(a); - h.run_steps(1); - h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - for i in 1..=4 { - h.event(Event::PointerMoved(a + vec2(30.0 * i as f32, 0.0))); - h.run_steps(1); - } - h.event(Event::PointerButton { pos: a + vec2(120.0, 0.0), button, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - let after = h.state().slide_rect.unwrap(); - assert!((before.min - after.min).length() < 1.0, "{button:?} drag moved the slide: {before:?} -> {after:?}"); - } - } - - /// A slide that fits has no scroll bars: a drag along the canvas edge is a marquee. - #[test] - fn a_fitted_slide_has_no_scroll_bars() { - let mut h = harness(); - let canvas = h.state().canvas_rect.unwrap(); - let a = pos2(canvas.center().x, canvas.max.y - 4.0); - let mut marquee = false; - drag_with(&mut h, a, a + vec2(150.0, 0.0), |app| marquee |= app.session.tool.marquee.is_some()); - assert!(marquee, "the drag selects on the canvas"); - } - - fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) { - let mut s = Session::with_new(); - let data = json!([["Hello world", "b"], ["c", ""]]); - let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap(); - let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); - s.execute("edit.deselect", &json!({})).unwrap(); - 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(s, Services::default()), - ); - // The app keeps requesting repaints, so step frames instead of run(). - harness.run_steps(2); - (harness, id) - } - - /// Screen position of slide point (x, y). - fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 { - Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y)) - } - - /// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click). - fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { - for pressed in [true, false] { - let t = harness.ctx.input(|i| i.time) + 0.05; - harness.input_mut().time = Some(t); - harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - harness.step(); - } - } - - /// The first empty placeholder and the paragraph after Enter keep a list marker and indent. - #[test] - fn empty_bullet_preview_follows_click_type_enter_and_toggle() { - let mut h = harness(); - h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap(); - h.run_steps(3); - let st = h.state().session.active().unwrap(); - let sh = st - .shapes() - .iter() - .find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj)) - .unwrap(); - let id = sh.id; - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let p = screen(h.state(), x.x + 40.0, x.y + 20.0); - click(&mut h, p); - h.run_steps(3); - let caret = |h: &Harness<'_, SlideApp>| { - let st = h.state().session.active().unwrap(); - let ts = st.selection.text.as_ref().expect("click starts typing"); - assert_eq!(ts.shape, id); - deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0 - }; - let first = caret(&h); - h.event(Event::Text("First point".into())); - h.run_steps(3); - h.key_press(egui::Key::Enter); - h.run_steps(3); - let second = caret(&h); - assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent"); - assert!(second.y > first.y); - let st = h.state().session.active().unwrap(); - let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap(); - assert_eq!(l.empty_bullets.len(), 1); - assert_eq!(l.empty_bullets[0].para, 1); - let renders = h.state().textures.canvas_renders; - h.state_mut().run("text.exit", json!({})).unwrap(); - h.run_steps(3); - assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture"); - h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap(); - h.run_steps(3); - h.event(Event::Text("Second point".into())); - h.run_steps(3); - let st = h.state().session.active().unwrap(); - let ts = st.selection.text.as_ref().unwrap(); - assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point"); - assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty()); - h.key_press(egui::Key::Enter); - h.run_steps(3); - h.state_mut().run("format.bullets", json!({})).unwrap(); - h.run_steps(3); - let st = h.state().session.active().unwrap(); - assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty()); - } - - /// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked. - #[test] - fn double_click_in_a_table_cell_shows_the_caret_there() { - let (mut harness, id) = app_with_table(); - // The empty cell (1, 1) spans x 300..500, y 140..180. - let p = screen(harness.state(), 400.0, 160.0); - harness.hover_at(p); - harness.step(); - click(&mut harness, p); - click(&mut harness, p); - harness.run_steps(2); - let st = harness.state().session.active().unwrap(); - let ts = st.selection.text.clone().expect("editing the cell"); - assert_eq!((ts.shape, ts.cell), (id, Some((1, 1)))); - assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range"); - let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret"); - for q in [top, bottom] { - assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); - } - // A click in another cell moves the caret there. - let p = screen(harness.state(), 110.0, 120.0); - click(&mut harness, p); - harness.run_steps(1); - let st = harness.state().session.active().unwrap(); - let ts = st.selection.text.clone().unwrap(); - assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0))); - let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); - assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); - } - - /// Table borders show a resize cursor and drag to resize columns. - #[test] - fn table_borders_resize_with_the_pointer() { - let (mut harness, id) = app_with_table(); - let p = screen(harness.state(), 300.0, 120.0); - harness.hover_at(p); - harness.step(); - harness.step(); - assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn); - let q = screen(harness.state(), 340.0, 120.0); - harness.drag_at(p); - harness.step(); - for k in 1..=4 { - harness.hover_at(p + (q - p) * k as f32 / 4.0); - harness.step(); - } - harness.drop_at(q); - harness.run_steps(2); - let st = harness.state().session.active().unwrap(); - let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") }; - assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); - } - - fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) { - h.event(Event::PointerMoved(pos)); - h.step(); - for pressed in [true, false] { - h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); - h.step(); - } - h.run_steps(2); - } - - /// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking - /// a point offers Open Path, then Close Path once the outline is open. - #[test] - fn edit_points_menu_sets_segment_types_and_opens_the_path() { - let mut h = harness(); - h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap(); - h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap(); - h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap(); - h.run_steps(3); - let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap(); - // The middle of the right edge: the segment ending at point 2. - let p = screen(h.state(), 500.0, 250.0); - right_click(&mut h, p); - h.get_by_label("Curved Segment").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"])); - // The bottom-right corner: Open Path breaks the outline there. - let p = screen(h.state(), 500.0, 350.0); - right_click(&mut h, p); - h.get_by_label("Open Path").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5)); - right_click(&mut h, p); - h.get_by_label("Close Path").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4)); - } - - /// In Edit Points, right-clicking a segment offers Add Point at the pointer, Delete Segment on - /// a closed outline, and Open Path at the segment's start point. - #[test] - fn edit_points_segment_menu_adds_points_and_deletes_segments() { - let mut h = harness(); - h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap(); - h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap(); - h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap(); - h.run_steps(3); - let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap(); - // A quarter of the way along the top edge: the new point goes there. - let p = screen(h.state(), 275.0, 150.0); - right_click(&mut h, p); - h.get_by_label("Add Point").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["paths"][0], json!([[200.0, 150.0], [275.0, 150.0], [500.0, 150.0], [500.0, 350.0], [200.0, 350.0]])); - // The right edge: Open Path opens the outline at its top end. - let p = screen(h.state(), 500.0, 250.0); - right_click(&mut h, p); - h.get_by_label("Open Path").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["paths"][0][0], json!([500.0, 150.0])); - assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(6)); - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.run_steps(3); - // Delete Segment removes the right edge: the outline now runs from its bottom end to its top end. - right_click(&mut h, p); - h.get_by_label("Delete Segment").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["paths"][0], json!([[500.0, 350.0], [200.0, 350.0], [200.0, 150.0], [275.0, 150.0], [500.0, 150.0]])); - assert_eq!(q(&mut h)["segments"][0][0], json!(null)); - // On the open outline Delete Segment is grayed out; Close Path joins the ends again. - let p = screen(h.state(), 350.0, 350.0); - right_click(&mut h, p); - assert!(h.get_by_label("Delete Segment").accesskit_node().is_disabled()); - h.get_by_label("Close Path").click(); - h.run_steps(3); - assert_eq!(q(&mut h)["segments"][0][0], json!("straight")); - } - - #[test] - fn grid_step_follows_spacing_and_rejects_unusable_values() { - let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); - assert!((step - 36.0).abs() < 1e-3, "{step}"); - assert!(super::grid_line_step(6.0, 1.0).is_some()); - assert!(super::grid_line_step(0.0, 1.0).is_none()); - assert!(super::grid_line_step(f64::NAN, 1.0).is_none()); - assert!(super::grid_line_step(1_000.0, 1.0).is_none()); - assert!(super::grid_line_step(6.0, 0.0).is_none()); - assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); - } - - /// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the - /// user can see the tool is waiting for a click. - #[test] - fn format_painter_sets_the_cursor() { - let mut h = harness(); - // Hover over the canvas so the pointer function runs and sets the cursor. - h.hover_at(egui::pos2(600.0, 400.0)); - h.run_steps(2); - let before = h.output().platform_output.cursor_icon; - assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet"); - // Arming the format painter overrides the cursor to Copy (#830). - h.state_mut().session.painter = Some((Default::default(), false)); - h.run_steps(3); - let after = h.output().platform_output.cursor_icon; - assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor"); - } - - /// #830: the Animation Painter arms the same cursor. - #[test] - fn animation_painter_sets_the_cursor() { - let mut h = harness(); - h.hover_at(egui::pos2(600.0, 400.0)); - h.run_steps(2); - let before = h.output().platform_output.cursor_icon; - assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet"); - h.state_mut().session.anim_painter = Some((vec![], false)); - h.run_steps(3); - let after = h.output().platform_output.cursor_icon; - assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor"); - } -} +mod tests; #[cfg(test)] #[path = "ruler_tests.rs"] diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs new file mode 100644 index 000000000..802dec29a --- /dev/null +++ b/crates/ui-egui/src/canvas/tests.rs @@ -0,0 +1,569 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::Arc; + +use deckcraft_engine::Session; +use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; + +use super::*; +use crate::{Services, SlideApp}; + +/// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's +/// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old +/// fixed handle-to-cursor mapping showed NW-SE there. +#[test] +fn resize_cursor_tracks_rotation() { + let mut s = Session::with_new(); + // A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape + // rotation cleanly moves the cursor to the next of the four resize directions. + let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"].as_u64().unwrap() + as u32; + let sid = deckcraft_model::ShapeId(id); + // Unrotated: the NW handle (0) drags the NW-SE diagonal. + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe); + assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw); + assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw); + assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical); + assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal); + // 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical. + { + let doc = s.doc_mut().unwrap(); + let pres = Arc::make_mut(&mut doc.doc); + let slide = Arc::make_mut(&mut pres.slides[0]); + if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid) + && let Some(x) = sh.xfrm.as_mut() + { + x.rot = 45.0; + } + } + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically"); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally"); + // 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW. + { + let doc = s.doc_mut().unwrap(); + let pres = Arc::make_mut(&mut doc.doc); + let slide = Arc::make_mut(&mut pres.slides[0]); + if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid) + && let Some(x) = sh.xfrm.as_mut() + { + x.rot = 90.0; + } + } + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW"); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE"); + assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal"); +} + +/// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The +/// corner's direction is the diagonal, not the line to the box's centre, so a wide +/// rectangle's NW corner still drags along the NW-SE diagonal. +#[test] +fn a_wide_shape_keeps_its_corner_cursors_diagonal() { + let mut s = Session::with_new(); + let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"].as_u64().unwrap() + as u32; + let sid = deckcraft_model::ShapeId(id); + assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE"); + assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE"); + assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW"); + assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW"); + // The edges keep their own cursors. + assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically"); + assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally"); +} + +/// #745: a placeholder that inherits its box from the layout still shows a resize cursor. +/// Its handles are drawn from the inherited box, so the cursor follows that one. +#[test] +fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() { + let s = Session::with_new(); + let st = s.doc().unwrap(); + let ph = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.xfrm.is_none() && sh.ph.is_some()) + .expect("the new slide has a placeholder without a box of its own") + .id; + assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE"); + assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW"); + assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically"); +} + +fn harness() -> Harness<'static, SlideApp> { + let mut h = 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()), + ); + h.run_steps(3); + h +} + +/// The slide point (in slide points) under screen position `p`. +fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 { + let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) +} + +/// Test helper: inject a platform-command-modified line wheel event and run thirty harness +/// steps. +fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, lines), + phase: TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: Modifiers::COMMAND, + }; + h.event(wheel); + // egui spreads a wheel notch over several frames. + h.run_steps(30); +} + +/// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel +/// grid (#184). +fn assert_near(a: Pos2, b: Pos2, what: &str) { + assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}"); +} + +/// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. +#[test] +fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() { + let mut h = harness(); + let fit = h.state().canvas_scale; + let r = h.state().slide_rect.unwrap(); + let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let under = slide_point(&h, p); + + h.event(Event::Zoom(2.0)); + h.run_steps(2); + assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3); + assert_near(under, slide_point(&h, p), "pinch in"); + + ctrl_wheel(&mut h, 1.0); + assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in"); + assert_near(under, slide_point(&h, p), "wheel in"); + + ctrl_wheel(&mut h, -1.0); + ctrl_wheel(&mut h, -1.0); + assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out"); + assert_near(under, slide_point(&h, p), "wheel out"); + + h.event(Event::Zoom(0.8)); + h.run_steps(2); + assert_near(under, slide_point(&h, p), "pinch out"); + + // Zoomed out below the window size, the slide is centred again. + h.event(Event::Zoom(0.25)); + h.run_steps(2); + assert!(h.state().canvas_scale < fit); + let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); + assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}"); +} + +/// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is. +#[test] +fn zoom_commands_keep_the_middle_of_the_view() { + let mut h = harness(); + let c = h.state().canvas_rect.unwrap().center(); + let mid = slide_point(&h, c); + for _ in 0..4 { + h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom in"); + } + h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom out"); +} + +/// Drags with the primary button from `a` to `b`; `during` runs after every move. +fn drag_with(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2, mut during: impl FnMut(&SlideApp)) { + h.hover_at(a); + h.run_steps(1); + h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=8 { + h.event(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + during(h.state()); + } + h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// #1157: a slide zoomed past the window has a scroll bar on each axis that overflows, and +/// dragging its thumb pans the slide, not a marquee on the canvas. +#[test] +fn scroll_bars_pan_a_zoomed_slide() { + let mut h = harness(); + let fit = h.state().canvas_scale; + h.state_mut().ui.zoom = Some(fit * 3.0); + h.run_steps(3); + let canvas = h.state().canvas_rect.unwrap(); + let before = h.state().slide_rect.unwrap(); + let no_marquee = |app: &SlideApp| assert!(app.session.tool.marquee.is_none() && !app.session.tool.dragging(), "the bar drag reached the slide"); + + // Zoomed about the middle, so each thumb sits in the middle of its track. + let bottom = pos2(canvas.center().x, canvas.max.y - 4.0); + drag_with(&mut h, bottom, bottom + vec2(150.0, 0.0), no_marquee); + let after = h.state().slide_rect.unwrap(); + assert!(before.min.x - after.min.x > 150.0, "the horizontal thumb pans right: {before:?} -> {after:?}"); + assert!((before.min.y - after.min.y).abs() < 1.0, "only across: {before:?} -> {after:?}"); + + let right = pos2(canvas.max.x - 4.0, canvas.center().y); + drag_with(&mut h, right, right + vec2(0.0, 100.0), no_marquee); + let down = h.state().slide_rect.unwrap(); + assert!(after.min.y - down.min.y > 100.0, "the vertical thumb pans down: {after:?} -> {down:?}"); + assert!((after.min.x - down.min.x).abs() < 1.0, "only down: {after:?} -> {down:?}"); + + // A click on the track beside the thumb pages back toward it. + let left = pos2(canvas.min.x + 20.0, canvas.max.y - 4.0); + h.hover_at(left); + h.run_steps(1); + h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let paged = h.state().slide_rect.unwrap(); + assert!(paged.min.x - down.min.x > 100.0, "a track click pages left: {down:?} -> {paged:?}"); + assert!(h.state().session.tool.marquee.is_none()); + + // The ends of the tracks reach the slide's far edges. + drag_with(&mut h, right, right + vec2(0.0, 2000.0), |_| {}); + let end = h.state().slide_rect.unwrap(); + assert!((end.max.y - (canvas.max.y - 28.0)).abs() < 2.0, "the bottom of the slide comes into view: {end:?} in {canvas:?}"); +} + +/// A press on the thumb that ends just past its end is a nudge, not a page: the track click +/// is decided where the button went down, not where it came up. +#[test] +fn a_short_press_on_the_thumb_does_not_page() { + let mut h = harness(); + let fit = h.state().canvas_scale; + h.state_mut().ui.zoom = Some(fit * 3.0); + h.run_steps(3); + let (canvas, before) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); + let overflow = vec2((before.width() + 56.0 - canvas.width()).max(0.0), (before.height() + 56.0 - canvas.height()).max(0.0)); + let offset = canvas.min + vec2(28.0, 28.0) - before.min; + let (_, thumb, _) = scroll_bar(canvas, 0.0, overflow, offset, 0).unwrap(); + let (a, b) = (pos2(thumb.max.x - 3.0, canvas.max.y - 4.0), pos2(thumb.max.x + 3.0, canvas.max.y - 4.0)); + h.hover_at(a); + h.run_steps(1); + h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(b)); + h.run_steps(1); + h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let after = h.state().slide_rect.unwrap(); + let moved = before.min.x - after.min.x; + assert!((0.0..40.0).contains(&moved), "a 6 pt nudge of the thumb, not a page: {before:?} -> {after:?}"); +} + +/// Only the primary button drags a thumb: a right or middle drag after a left one leaves the +/// slide where it is. +#[test] +fn only_the_primary_button_drags_a_thumb() { + let mut h = harness(); + let fit = h.state().canvas_scale; + h.state_mut().ui.zoom = Some(fit * 3.0); + h.run_steps(3); + let canvas = h.state().canvas_rect.unwrap(); + let bottom = pos2(canvas.center().x, canvas.max.y - 4.0); + drag_with(&mut h, bottom, bottom + vec2(40.0, 0.0), |_| {}); + let before = h.state().slide_rect.unwrap(); + for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] { + let a = pos2(canvas.center().x + 40.0, canvas.max.y - 4.0); + h.hover_at(a); + h.run_steps(1); + h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=4 { + h.event(Event::PointerMoved(a + vec2(30.0 * i as f32, 0.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: a + vec2(120.0, 0.0), button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let after = h.state().slide_rect.unwrap(); + assert!((before.min - after.min).length() < 1.0, "{button:?} drag moved the slide: {before:?} -> {after:?}"); + } +} + +/// A slide that fits has no scroll bars: a drag along the canvas edge is a marquee. +#[test] +fn a_fitted_slide_has_no_scroll_bars() { + let mut h = harness(); + let canvas = h.state().canvas_rect.unwrap(); + let a = pos2(canvas.center().x, canvas.max.y - 4.0); + let mut marquee = false; + drag_with(&mut h, a, a + vec2(150.0, 0.0), |app| marquee |= app.session.tool.marquee.is_some()); + assert!(marquee, "the drag selects on the canvas"); +} + +fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) { + let mut s = Session::with_new(); + let data = json!([["Hello world", "b"], ["c", ""]]); + let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("edit.deselect", &json!({})).unwrap(); + 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(s, Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + (harness, id) +} + +/// Screen position of slide point (x, y). +fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 { + Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y)) +} + +/// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click). +fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + let t = harness.ctx.input(|i| i.time) + 0.05; + harness.input_mut().time = Some(t); + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } +} + +/// The first empty placeholder and the paragraph after Enter keep a list marker and indent. +#[test] +fn empty_bullet_preview_follows_click_type_enter_and_toggle() { + let mut h = harness(); + h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let sh = + st.shapes().iter().find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap(); + let id = sh.id; + let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); + let p = screen(h.state(), x.x + 40.0, x.y + 20.0); + click(&mut h, p); + h.run_steps(3); + let caret = |h: &Harness<'_, SlideApp>| { + let st = h.state().session.active().unwrap(); + let ts = st.selection.text.as_ref().expect("click starts typing"); + assert_eq!(ts.shape, id); + deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0 + }; + let first = caret(&h); + h.event(Event::Text("First point".into())); + h.run_steps(3); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let second = caret(&h); + assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent"); + assert!(second.y > first.y); + let st = h.state().session.active().unwrap(); + let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap(); + assert_eq!(l.empty_bullets.len(), 1); + assert_eq!(l.empty_bullets[0].para, 1); + let renders = h.state().textures.canvas_renders; + h.state_mut().run("text.exit", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture"); + h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap(); + h.run_steps(3); + h.event(Event::Text("Second point".into())); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let ts = st.selection.text.as_ref().unwrap(); + assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point"); + assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty()); + h.key_press(egui::Key::Enter); + h.run_steps(3); + h.state_mut().run("format.bullets", json!({})).unwrap(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty()); +} + +/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked. +#[test] +fn double_click_in_a_table_cell_shows_the_caret_there() { + let (mut harness, id) = app_with_table(); + // The empty cell (1, 1) spans x 300..500, y 140..180. + let p = screen(harness.state(), 400.0, 160.0); + harness.hover_at(p); + harness.step(); + click(&mut harness, p); + click(&mut harness, p); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().expect("editing the cell"); + assert_eq!((ts.shape, ts.cell), (id, Some((1, 1)))); + assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range"); + let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret"); + for q in [top, bottom] { + assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); + } + // A click in another cell moves the caret there. + let p = screen(harness.state(), 110.0, 120.0); + click(&mut harness, p); + harness.run_steps(1); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().unwrap(); + assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0))); + let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); + assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); +} + +/// Table borders show a resize cursor and drag to resize columns. +#[test] +fn table_borders_resize_with_the_pointer() { + let (mut harness, id) = app_with_table(); + let p = screen(harness.state(), 300.0, 120.0); + harness.hover_at(p); + harness.step(); + harness.step(); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn); + let q = screen(harness.state(), 340.0, 120.0); + harness.drag_at(p); + harness.step(); + for k in 1..=4 { + harness.hover_at(p + (q - p) * k as f32 / 4.0); + harness.step(); + } + harness.drop_at(q); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") }; + assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); +} + +fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +/// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking +/// a point offers Open Path, then Close Path once the outline is open. +#[test] +fn edit_points_menu_sets_segment_types_and_opens_the_path() { + let mut h = harness(); + h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap(); + h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap(); + h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap(); + h.run_steps(3); + let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap(); + // The middle of the right edge: the segment ending at point 2. + let p = screen(h.state(), 500.0, 250.0); + right_click(&mut h, p); + h.get_by_label("Curved Segment").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"])); + // The bottom-right corner: Open Path breaks the outline there. + let p = screen(h.state(), 500.0, 350.0); + right_click(&mut h, p); + h.get_by_label("Open Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5)); + right_click(&mut h, p); + h.get_by_label("Close Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4)); +} + +/// In Edit Points, right-clicking a segment offers Add Point at the pointer, Delete Segment on +/// a closed outline, and Open Path at the segment's start point. +#[test] +fn edit_points_segment_menu_adds_points_and_deletes_segments() { + let mut h = harness(); + h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap(); + h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap(); + h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap(); + h.run_steps(3); + let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap(); + // A quarter of the way along the top edge: the new point goes there. + let p = screen(h.state(), 275.0, 150.0); + right_click(&mut h, p); + h.get_by_label("Add Point").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0], json!([[200.0, 150.0], [275.0, 150.0], [500.0, 150.0], [500.0, 350.0], [200.0, 350.0]])); + // The right edge: Open Path opens the outline at its top end. + let p = screen(h.state(), 500.0, 250.0); + right_click(&mut h, p); + h.get_by_label("Open Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0][0], json!([500.0, 150.0])); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(6)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + // Delete Segment removes the right edge: the outline now runs from its bottom end to its top end. + right_click(&mut h, p); + h.get_by_label("Delete Segment").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0], json!([[500.0, 350.0], [200.0, 350.0], [200.0, 150.0], [275.0, 150.0], [500.0, 150.0]])); + assert_eq!(q(&mut h)["segments"][0][0], json!(null)); + // On the open outline Delete Segment is grayed out; Close Path joins the ends again. + let p = screen(h.state(), 350.0, 350.0); + right_click(&mut h, p); + assert!(h.get_by_label("Delete Segment").accesskit_node().is_disabled()); + h.get_by_label("Close Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["segments"][0][0], json!("straight")); +} + +#[test] +fn grid_step_follows_spacing_and_rejects_unusable_values() { + let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); + assert!((step - 36.0).abs() < 1e-3, "{step}"); + assert!(super::grid_line_step(6.0, 1.0).is_some()); + assert!(super::grid_line_step(0.0, 1.0).is_none()); + assert!(super::grid_line_step(f64::NAN, 1.0).is_none()); + assert!(super::grid_line_step(1_000.0, 1.0).is_none()); + assert!(super::grid_line_step(6.0, 0.0).is_none()); + assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); +} + +/// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the +/// user can see the tool is waiting for a click. +#[test] +fn format_painter_sets_the_cursor() { + let mut h = harness(); + // Hover over the canvas so the pointer function runs and sets the cursor. + h.hover_at(egui::pos2(600.0, 400.0)); + h.run_steps(2); + let before = h.output().platform_output.cursor_icon; + assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet"); + // Arming the format painter overrides the cursor to Copy (#830). + h.state_mut().session.painter = Some((Default::default(), false)); + h.run_steps(3); + let after = h.output().platform_output.cursor_icon; + assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor"); +} + +/// #830: the Animation Painter arms the same cursor. +#[test] +fn animation_painter_sets_the_cursor() { + let mut h = harness(); + h.hover_at(egui::pos2(600.0, 400.0)); + h.run_steps(2); + let before = h.output().platform_output.cursor_icon; + assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet"); + h.state_mut().session.anim_painter = Some((vec![], false)); + h.run_steps(3); + let after = h.output().platform_output.cursor_icon; + assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor"); +} From 8528674c577249640b89c2cb217c7e5321cd43c7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:31 +0000 Subject: [PATCH 12/90] refactor(ui-egui): move dialogs tests to dialogs/tests.rs --- crates/ui-egui/src/dialogs.rs | 292 +--------------------------- crates/ui-egui/src/dialogs/tests.rs | 288 +++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 291 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/tests.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4de8ffe19..38abe13f7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3636,297 +3636,7 @@ mod alt_text_tests { } #[cfg(test)] -mod tests { - use deckcraft_model::text::{Action, Hyperlink, Run, TextBody}; - use deckcraft_model::{Presentation, SlideId}; - - use super::{ - CurrentLink, HyperlinkSeed, common_click, hyperlink_command, hyperlink_seed_of, hyperlink_should_apply, link_covering, seed_from, - seed_of_link, - }; - - fn link(action: Action, tip: &str) -> Hyperlink { - Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None } - } - - fn url(address: &str, tip: &str) -> Hyperlink { - let mut h = link(Action::Url { url: address.into() }, tip); - h.highlight_click = tip == "glow"; - h - } - - /// Header & Footer ▸ Apply without picking a format leaves a slide's existing date field in - /// its own format instead of rewriting it to the default one. - #[test] - fn header_footer_apply_keeps_an_existing_date_format() { - use std::sync::Arc; - - use egui::vec2; - use egui_kittest::kittest::Queryable as _; - use serde_json::json; - - use crate::SlideApp; - let mut session = deckcraft_engine::Session::with_new(); - session.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime4", "all": true})).unwrap(); - // A file whose document-level format is unset, as an imported deck's is. - Arc::make_mut(&mut session.doc_mut().unwrap().doc).header_footer.date_format = String::new(); - let date_fields = |app: &SlideApp| -> Vec { - app.session - .doc() - .unwrap() - .doc - .slides - .iter() - .flat_map(|sl| sl.shapes.iter()) - .filter(|sh| sh.ph_type() == Some(deckcraft_model::PhType::Date)) - .flat_map(|sh| sh.text.iter()) - .flat_map(|t| t.paragraphs.iter()) - .flat_map(|p| p.runs.iter()) - .filter_map(|r| match &r.kind { - deckcraft_model::text::RunKind::Field { field } => Some(field.clone()), - _ => None, - }) - .collect() - }; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(session, crate::Services::default()), - ); - harness.run_steps(2); - assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()]); - harness.state_mut().run("app.dialog", json!({"id": "headerFooter"})).unwrap(); - harness.run_steps(2); - harness.get_by_label("Apply").click_accesskit(); - harness.run_steps(3); - assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()], "the dialog left the field's format alone"); - } - - /// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171). - #[test] - fn export_dialog_writes_an_animated_gif() { - use std::cell::RefCell; - - use egui::vec2; - use egui_kittest::kittest::Queryable as _; - use serde_json::json; - - use crate::SlideApp; - let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("deck.gif"); - let picked = path.to_string_lossy().to_string(); - let suggested = std::rc::Rc::new(RefCell::new(String::new())); - let seen = suggested.clone(); - let services = crate::Services { - pick_save: Some(Box::new(move |s: &str| { - *seen.borrow_mut() = s.to_string(); - Some(picked.clone()) - })), - ..Default::default() - }; - let mut session = deckcraft_engine::Session::with_new(); - session.execute("slide.new", &json!({})).unwrap(); - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(session, services), - ); - harness.run_steps(2); - harness.state_mut().run("app.exportDialog", json!({})).unwrap(); - harness.run_steps(2); - harness.get_by_label("Animated GIF (.gif)").click_accesskit(); - harness.run_steps(2); - assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif")); - harness.get_by_label("Seconds per slide:"); - harness.get_by_label("Loop forever").click_accesskit(); - harness.run_steps(2); - // A range outside the deck keeps the dialog open with a message, and nothing is written. - harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into()); - harness.run_steps(2); - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - assert!(harness.state().dialog.is_some()); - assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status); - assert!(!path.exists()); - harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into()); - harness.run_steps(2); - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); - assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow()); - let bytes = std::fs::read(&path).unwrap(); - assert!(bytes.starts_with(b"GIF89a")); - assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default"); - assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The Export… dialog offers "Transparent background" for PNG only, and the picture it - /// writes then leaves the slide background out (#784). - #[test] - fn export_dialog_writes_a_png_with_a_transparent_background() { - use egui::vec2; - use egui_kittest::kittest::Queryable as _; - use serde_json::json; - - use crate::SlideApp; - let dir = std::env::temp_dir().join(format!("deckcraft-ui-png-clear-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("slide.png"); - let picked = path.to_string_lossy().to_string(); - let services = crate::Services { pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() }; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(deckcraft_engine::Session::with_new(), services), - ); - harness.run_steps(2); - harness.state_mut().run("app.exportDialog", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.query_by_label("Transparent background").is_none(), "PDF has no transparent background"); - harness.get_by_label("JPEG image (current slide)").click_accesskit(); - harness.run_steps(2); - assert!(harness.query_by_label("Transparent background").is_none(), "nor has JPEG"); - harness.get_by_label("PNG images (current slide)").click_accesskit(); - harness.run_steps(2); - harness.get_by_label("Transparent background").click_accesskit(); - harness.run_steps(2); - assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("pngTransparent")).map(String::as_str), Some("true")); - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); - let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn no_link_opens_as_a_new_web_address() { - let seed = seed_from(CurrentLink::None, &[]); - assert_eq!(seed, HyperlinkSeed::blank()); - assert!(hyperlink_should_apply(&seed, "url", "https://", "", 0)); - } - - #[test] - fn web_email_and_slide_links_fill_the_dialog() { - let ids = [SlideId(7), SlideId(9)]; - let web = seed_of_link(&url("https://example.org", "See"), &ids); - assert_eq!(web.mode, "url"); - assert_eq!(web.url, "https://example.org"); - assert_eq!(web.tip, "See"); - assert!(!web.preserve); - assert!(hyperlink_should_apply(&web, "url", "https://example.org", "", 0)); - - let mail = seed_of_link(&url("mailto:a@b.org", "Write"), &ids); - assert_eq!(mail.mode, "email"); - assert_eq!(mail.email, "a@b.org"); - assert_eq!(mail.tip, "Write"); - assert_eq!(hyperlink_command("email", "", "a@b.org", 0, "Write", false)["url"], "mailto:a@b.org"); - - let slide = seed_of_link(&link(Action::Slide { slide: SlideId(9) }, "There"), &ids); - assert_eq!(slide.mode, "slide"); - assert_eq!(slide.slide, 1); - assert_eq!(slide.tip, "There"); - assert!(!slide.preserve); - - let glow = url("https://example.org", "glow"); - assert!(glow.highlight_click); - assert!(seed_of_link(&glow, &ids).highlight); - assert_eq!(hyperlink_command("url", "https://example.org", "", 0, "glow", true)["highlightClick"], true); - } - - #[test] - fn actions_this_dialog_does_not_edit_survive_ok() { - let next = seed_of_link(&link(Action::NextSlide, "On"), &[]); - assert!(next.preserve); - assert_eq!(next.tip, "On"); - assert_eq!(next.url, ""); - assert!(!hyperlink_should_apply(&next, "url", "", "", 0)); - assert!(hyperlink_should_apply(&next, "url", "https://example.org", "", 0)); - - let gone = seed_of_link(&link(Action::Slide { slide: SlideId(99) }, ""), &[SlideId(1)]); - assert!(gone.preserve, "a missing slide is not retargeted to the first slide"); - assert!(!hyperlink_should_apply(&gone, "url", "", "", 0)); - - let mixed = seed_from(CurrentLink::Mixed, &[]); - assert!(mixed.preserve); - assert!(!hyperlink_should_apply(&mixed, "url", "", "", 0)); - assert!(hyperlink_should_apply(&mixed, "email", "", "a@b.org", 0)); - } - - #[test] - fn a_text_span_reports_the_link_it_covers() { - let mut body = TextBody::from_text("Hello world"); - body.paragraphs[0].runs.clear(); - body.paragraphs[0].runs.push(Run::new("Hello ")); - let mut world = Run::new("world"); - world.props.link = Some(url("https://example.org", "tip")); - body.paragraphs[0].runs.push(world); - - assert_eq!(link_covering(&body, (0, 6), (0, 6)), CurrentLink::None, "a caret inserts; it does not edit the run beside it"); - match link_covering(&body, (0, 6), (0, 11)) { - CurrentLink::One(h) => assert_eq!(h.tooltip, "tip"), - other => panic!("linked word, got {other:?}"), - } - assert_eq!(link_covering(&body, (0, 0), (0, 11)), CurrentLink::Mixed); - - let mut other = url("https://example.org", "tip"); - other.tooltip = "other".into(); - assert_eq!(common_click([None, None]), CurrentLink::None); - assert!(matches!(common_click([Some(url("https://example.org", "tip"))]), CurrentLink::One(_))); - assert_eq!(common_click([Some(url("https://example.org", "tip")), Some(other)]), CurrentLink::Mixed); - assert_eq!(common_click(std::iter::empty()), CurrentLink::None); - } - - #[test] - fn the_open_dialog_reads_the_caret_word_and_the_shape_click() { - let mut doc = Presentation::default(); - let shape_id = { - let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); - let mut body = TextBody::from_text("Hello world"); - body.paragraphs[0].runs.clear(); - body.paragraphs[0].runs.push(Run::new("Hello ")); - let mut world = Run::new("world"); - world.props.link = Some(url("https://example.org", "tip")); - body.paragraphs[0].runs.push(world); - slide.shapes[0].text = Some(body); - slide.shapes[0].id - }; - let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); - st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 8), caret: (0, 8), ..Default::default() }); - let seed = hyperlink_seed_of(Some(&st)); - assert_eq!(seed.mode, "url"); - assert_eq!(seed.url, "https://example.org"); - assert_eq!(seed.tip, "tip"); - assert!(!seed.preserve); - - st.selection.text = None; - st.selection.shapes = vec![shape_id]; - { - let doc = std::sync::Arc::make_mut(&mut st.doc); - let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); - let shape = slide.shapes.iter_mut().find(|sh| sh.id == shape_id).unwrap(); - let mut click = url("https://shape.example", "shape tip"); - click.highlight_click = true; - shape.click = Some(click); - } - let seed = hyperlink_seed_of(Some(&st)); - assert_eq!(seed.url, "https://shape.example"); - assert_eq!(seed.tip, "shape tip"); - assert!(seed.highlight); - - st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 11), caret: (0, 11), ..Default::default() }); - let seed = hyperlink_seed_of(Some(&st)); - assert_eq!(seed, HyperlinkSeed::blank(), "a caret at the end of the word inserts a new link"); - } -} +mod tests; #[cfg(test)] mod zoom_tests { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs new file mode 100644 index 000000000..7378a6a8b --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -0,0 +1,288 @@ +use deckcraft_model::text::{Action, Hyperlink, Run, TextBody}; +use deckcraft_model::{Presentation, SlideId}; + +use super::{ + CurrentLink, HyperlinkSeed, common_click, hyperlink_command, hyperlink_seed_of, hyperlink_should_apply, link_covering, seed_from, seed_of_link, +}; + +fn link(action: Action, tip: &str) -> Hyperlink { + Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None } +} + +fn url(address: &str, tip: &str) -> Hyperlink { + let mut h = link(Action::Url { url: address.into() }, tip); + h.highlight_click = tip == "glow"; + h +} + +/// Header & Footer ▸ Apply without picking a format leaves a slide's existing date field in +/// its own format instead of rewriting it to the default one. +#[test] +fn header_footer_apply_keeps_an_existing_date_format() { + use std::sync::Arc; + + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let mut session = deckcraft_engine::Session::with_new(); + session.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime4", "all": true})).unwrap(); + // A file whose document-level format is unset, as an imported deck's is. + Arc::make_mut(&mut session.doc_mut().unwrap().doc).header_footer.date_format = String::new(); + let date_fields = |app: &SlideApp| -> Vec { + app.session + .doc() + .unwrap() + .doc + .slides + .iter() + .flat_map(|sl| sl.shapes.iter()) + .filter(|sh| sh.ph_type() == Some(deckcraft_model::PhType::Date)) + .flat_map(|sh| sh.text.iter()) + .flat_map(|t| t.paragraphs.iter()) + .flat_map(|p| p.runs.iter()) + .filter_map(|r| match &r.kind { + deckcraft_model::text::RunKind::Field { field } => Some(field.clone()), + _ => None, + }) + .collect() + }; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, crate::Services::default()), + ); + harness.run_steps(2); + assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()]); + harness.state_mut().run("app.dialog", json!({"id": "headerFooter"})).unwrap(); + harness.run_steps(2); + harness.get_by_label("Apply").click_accesskit(); + harness.run_steps(3); + assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()], "the dialog left the field's format alone"); +} + +/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171). +#[test] +fn export_dialog_writes_an_animated_gif() { + use std::cell::RefCell; + + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.gif"); + let picked = path.to_string_lossy().to_string(); + let suggested = std::rc::Rc::new(RefCell::new(String::new())); + let seen = suggested.clone(); + let services = crate::Services { + pick_save: Some(Box::new(move |s: &str| { + *seen.borrow_mut() = s.to_string(); + Some(picked.clone()) + })), + ..Default::default() + }; + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + harness.get_by_label("Animated GIF (.gif)").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif")); + harness.get_by_label("Seconds per slide:"); + harness.get_by_label("Loop forever").click_accesskit(); + harness.run_steps(2); + // A range outside the deck keeps the dialog open with a message, and nothing is written. + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status); + assert!(!path.exists()); + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow()); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default"); + assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The Export… dialog offers "Transparent background" for PNG only, and the picture it +/// writes then leaves the slide background out (#784). +#[test] +fn export_dialog_writes_a_png_with_a_transparent_background() { + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-png-clear-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("slide.png"); + let picked = path.to_string_lossy().to_string(); + let services = crate::Services { pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(deckcraft_engine::Session::with_new(), services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label("Transparent background").is_none(), "PDF has no transparent background"); + harness.get_by_label("JPEG image (current slide)").click_accesskit(); + harness.run_steps(2); + assert!(harness.query_by_label("Transparent background").is_none(), "nor has JPEG"); + harness.get_by_label("PNG images (current slide)").click_accesskit(); + harness.run_steps(2); + harness.get_by_label("Transparent background").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("pngTransparent")).map(String::as_str), Some("true")); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn no_link_opens_as_a_new_web_address() { + let seed = seed_from(CurrentLink::None, &[]); + assert_eq!(seed, HyperlinkSeed::blank()); + assert!(hyperlink_should_apply(&seed, "url", "https://", "", 0)); +} + +#[test] +fn web_email_and_slide_links_fill_the_dialog() { + let ids = [SlideId(7), SlideId(9)]; + let web = seed_of_link(&url("https://example.org", "See"), &ids); + assert_eq!(web.mode, "url"); + assert_eq!(web.url, "https://example.org"); + assert_eq!(web.tip, "See"); + assert!(!web.preserve); + assert!(hyperlink_should_apply(&web, "url", "https://example.org", "", 0)); + + let mail = seed_of_link(&url("mailto:a@b.org", "Write"), &ids); + assert_eq!(mail.mode, "email"); + assert_eq!(mail.email, "a@b.org"); + assert_eq!(mail.tip, "Write"); + assert_eq!(hyperlink_command("email", "", "a@b.org", 0, "Write", false)["url"], "mailto:a@b.org"); + + let slide = seed_of_link(&link(Action::Slide { slide: SlideId(9) }, "There"), &ids); + assert_eq!(slide.mode, "slide"); + assert_eq!(slide.slide, 1); + assert_eq!(slide.tip, "There"); + assert!(!slide.preserve); + + let glow = url("https://example.org", "glow"); + assert!(glow.highlight_click); + assert!(seed_of_link(&glow, &ids).highlight); + assert_eq!(hyperlink_command("url", "https://example.org", "", 0, "glow", true)["highlightClick"], true); +} + +#[test] +fn actions_this_dialog_does_not_edit_survive_ok() { + let next = seed_of_link(&link(Action::NextSlide, "On"), &[]); + assert!(next.preserve); + assert_eq!(next.tip, "On"); + assert_eq!(next.url, ""); + assert!(!hyperlink_should_apply(&next, "url", "", "", 0)); + assert!(hyperlink_should_apply(&next, "url", "https://example.org", "", 0)); + + let gone = seed_of_link(&link(Action::Slide { slide: SlideId(99) }, ""), &[SlideId(1)]); + assert!(gone.preserve, "a missing slide is not retargeted to the first slide"); + assert!(!hyperlink_should_apply(&gone, "url", "", "", 0)); + + let mixed = seed_from(CurrentLink::Mixed, &[]); + assert!(mixed.preserve); + assert!(!hyperlink_should_apply(&mixed, "url", "", "", 0)); + assert!(hyperlink_should_apply(&mixed, "email", "", "a@b.org", 0)); +} + +#[test] +fn a_text_span_reports_the_link_it_covers() { + let mut body = TextBody::from_text("Hello world"); + body.paragraphs[0].runs.clear(); + body.paragraphs[0].runs.push(Run::new("Hello ")); + let mut world = Run::new("world"); + world.props.link = Some(url("https://example.org", "tip")); + body.paragraphs[0].runs.push(world); + + assert_eq!(link_covering(&body, (0, 6), (0, 6)), CurrentLink::None, "a caret inserts; it does not edit the run beside it"); + match link_covering(&body, (0, 6), (0, 11)) { + CurrentLink::One(h) => assert_eq!(h.tooltip, "tip"), + other => panic!("linked word, got {other:?}"), + } + assert_eq!(link_covering(&body, (0, 0), (0, 11)), CurrentLink::Mixed); + + let mut other = url("https://example.org", "tip"); + other.tooltip = "other".into(); + assert_eq!(common_click([None, None]), CurrentLink::None); + assert!(matches!(common_click([Some(url("https://example.org", "tip"))]), CurrentLink::One(_))); + assert_eq!(common_click([Some(url("https://example.org", "tip")), Some(other)]), CurrentLink::Mixed); + assert_eq!(common_click(std::iter::empty()), CurrentLink::None); +} + +#[test] +fn the_open_dialog_reads_the_caret_word_and_the_shape_click() { + let mut doc = Presentation::default(); + let shape_id = { + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let mut body = TextBody::from_text("Hello world"); + body.paragraphs[0].runs.clear(); + body.paragraphs[0].runs.push(Run::new("Hello ")); + let mut world = Run::new("world"); + world.props.link = Some(url("https://example.org", "tip")); + body.paragraphs[0].runs.push(world); + slide.shapes[0].text = Some(body); + slide.shapes[0].id + }; + let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); + st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 8), caret: (0, 8), ..Default::default() }); + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed.mode, "url"); + assert_eq!(seed.url, "https://example.org"); + assert_eq!(seed.tip, "tip"); + assert!(!seed.preserve); + + st.selection.text = None; + st.selection.shapes = vec![shape_id]; + { + let doc = std::sync::Arc::make_mut(&mut st.doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let shape = slide.shapes.iter_mut().find(|sh| sh.id == shape_id).unwrap(); + let mut click = url("https://shape.example", "shape tip"); + click.highlight_click = true; + shape.click = Some(click); + } + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed.url, "https://shape.example"); + assert_eq!(seed.tip, "shape tip"); + assert!(seed.highlight); + + st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 11), caret: (0, 11), ..Default::default() }); + let seed = hyperlink_seed_of(Some(&st)); + assert_eq!(seed, HyperlinkSeed::blank(), "a caret at the end of the word inserts a new link"); +} From 0f4665ebe280fa982a9426c35409e478fcd00fa8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:32 +0000 Subject: [PATCH 13/90] refactor(engine): move design tests to cmd/design/tests.rs --- crates/engine/src/cmd/design.rs | 481 +------------------------- crates/engine/src/cmd/design/tests.rs | 478 +++++++++++++++++++++++++ 2 files changed, 479 insertions(+), 480 deletions(-) create mode 100644 crates/engine/src/cmd/design/tests.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 0b742f383..ece1f2017 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -1584,486 +1584,7 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph { } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use deckcraft_color::SchemeSlot; - use deckcraft_model::style::PictureMode; - use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes}; - use deckcraft_model::{Background, Fill}; - use serde_json::{Value, json}; - - use crate::{Session, UiRequest}; - - fn scheme(s: &Session) -> deckcraft_color::ColorScheme { - s.doc().unwrap().doc.masters[0].theme.colors.clone() - } - - fn font_scheme(s: &Session) -> FontScheme { - s.doc().unwrap().doc.masters[0].theme.fonts.clone() - } - - fn undo_len(s: &Session) -> usize { - s.doc().unwrap().history.undo.len() - } - - fn png() -> String { - let mut img = image::RgbaImage::new(2, 2); - img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255])); - let mut bytes = Vec::new(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); - crate::cmd::base64_encode(&bytes) - } - - fn picture(s: &Session) -> deckcraft_model::style::PictureFill { - match &s.doc().unwrap().doc.slides[0].background { - Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(), - other => panic!("expected a picture background, got {other:?}"), - } - } - - #[test] - fn layout_commands_reject_invalid_index() { - let mut s = Session::with_new(); - s.execute("master.insertLayout", &json!({})).unwrap(); - let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::(); - let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::>(); - let n = count(&s); - let before = names(&s); - for bad in [json!(-1), json!(1.5)] { - assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err()); - assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err()); - assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err()); - assert_eq!(count(&s), n); - assert_eq!(names(&s), before); - } - s.execute("master.deleteLayout", &json!({})).unwrap(); - assert_eq!(count(&s), n - 1); - } - - #[test] - fn civil_dates() { - assert_eq!(super::civil(0), (1970, 1, 1)); - assert_eq!(super::civil(20_734), (2026, 10, 8)); - // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. - assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); - assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); - assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); - assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); - } - - #[test] - fn colors_dialog_apply_and_reject() { - let mut s = Session::with_new(); - let original = scheme(&s); - let n = undo_len(&s); - s.execute("design.colors", &json!({})).unwrap(); - s.execute("design.colors", &Value::Null).unwrap(); - assert_eq!(scheme(&s), original); - assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); - assert_eq!(s.ui_requests.len(), 2); - match &s.ui_requests[0] { - UiRequest::Dialog { id, params } => { - assert_eq!(id, "themeColors"); - assert_eq!(params["name"], "DeckCraft"); - let colors = params["colors"].as_object().unwrap(); - assert_eq!(colors.len(), 12); - assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex())); - assert!(colors.contains_key("folHlink")); - } - other => panic!("expected a dialog, got {other:?}"), - } - - s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); - let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap(); - assert_eq!(scheme(&s).name, "Harbor"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); - s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); - - let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); - assert!(err.contains("no colour scheme `Nope`"), "{err}"); - assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); - assert!(s.execute("design.colors", &json!({"name": 1})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); - assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); - assert_eq!(scheme(&s).name, "Harbor"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 1); - - s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); - assert_eq!(scheme(&s).name, "Custom"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); - assert_eq!(undo_len(&s), n + 2); - - let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); - s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); - assert_eq!(scheme(&s).name, "Meadow"); - assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 3); - - s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); - assert_eq!(scheme(&s).name, "Talk"); - assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); - assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); - - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(scheme(&s).name, "Meadow"); - assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); - } - - #[test] - fn fonts_dialog_apply_and_reject() { - let mut s = Session::with_new(); - let original = font_scheme(&s); - let n = undo_len(&s); - s.execute("design.fonts", &json!({})).unwrap(); - s.execute("design.fonts", &Value::Null).unwrap(); - s.execute("design.fonts", &json!([1, 2])).unwrap(); - assert_eq!(font_scheme(&s), original); - assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); - assert_eq!(s.ui_requests.len(), 3); - match &s.ui_requests[0] { - UiRequest::Dialog { id, params } => { - assert_eq!(id, "themeFonts"); - assert_eq!(params["name"], original.name); - assert_eq!(params["major"], original.major.latin); - assert_eq!(params["minor"], original.minor.latin); - } - other => panic!("expected a dialog, got {other:?}"), - } - - s.execute("design.fonts", &json!({"name": "classic"})).unwrap(); - let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap(); - assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name"); - assert_eq!(undo_len(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); - s.execute("design.fonts", &json!({"name": "Classic"})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); - - let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string(); - assert!(err.contains("no font scheme `Nope`"), "{err}"); - assert!(s.execute("design.fonts", &json!({"name": " "})).is_err()); - assert!(s.execute("design.fonts", &json!({"name": 1})).is_err()); - assert!(s.execute("design.fonts", &json!({"major": ""})).is_err()); - assert!(s.execute("design.fonts", &json!({"major": " "})).is_err()); - assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err()); - assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err()); - assert_eq!(font_scheme(&s), classic); - assert_eq!(undo_len(&s), n + 1); - - s.edit(|doc, _| { - let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts; - fonts.major.ea = "EA Major".into(); - fonts.minor.cs = "CS Minor".into(); - fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() }); - Ok(()) - }) - .unwrap(); - let marked = font_scheme(&s); - let n = undo_len(&s); - - s.execute("design.fonts", &json!({"major": " Lato "})).unwrap(); - let custom = font_scheme(&s); - assert_eq!(custom.name, "Custom"); - assert_eq!(custom.major.latin, "Lato"); - assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major"); - assert_eq!(custom.major.ea, "EA Major"); - assert_eq!(custom.minor.cs, "CS Minor"); - assert_eq!(custom.major.scripts, marked.major.scripts); - assert_eq!(undo_len(&s), n + 1); - - s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Custom"); - assert_eq!(font_scheme(&s).major.latin, "Lato"); - assert_eq!(font_scheme(&s).minor.latin, "Roboto"); - assert_eq!(font_scheme(&s).major.ea, "EA Major"); - assert_eq!(undo_len(&s), n + 2); - - s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Classic"); - assert_eq!(font_scheme(&s).major.latin, "Inter"); - assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); - assert_eq!(font_scheme(&s).major.ea, ""); - assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); - assert_eq!(undo_len(&s), n + 3); - - s.edit(|doc, _| { - std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into(); - Ok(()) - }) - .unwrap(); - s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Talk"); - assert_eq!(font_scheme(&s).major.latin, "Inter"); - assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans"); - assert_eq!(font_scheme(&s).major.ea, "Kept"); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); - - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(font_scheme(&s).name, "Classic"); - assert_eq!(font_scheme(&s).major.ea, "Kept"); - assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); - } - - #[test] - fn background_picture_options_tile_transparency_and_copy() { - let mut s = Session::with_new(); - let n = undo_len(&s); - let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string(); - assert!(err.contains("picture background"), "{err}"); - assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err()); - assert_eq!(undo_len(&s), n); - - s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap(); - match &s.doc().unwrap().doc.slides[0].background { - Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()), - other => panic!("expected a solid background, got {other:?}"), - } - let n = undo_len(&s); - s.execute("design.background", &json!({"transparency": 0.25})).unwrap(); - assert_eq!(undo_len(&s), n, "the same transparency is not an undo step"); - - s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap(); - let pf = picture(&s); - assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6); - assert!(matches!(pf.mode, PictureMode::Tile { .. })); - let n = undo_len(&s); - - s.execute( - "design.background", - &json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}), - ) - .unwrap(); - match picture(&s).mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0)); - assert_eq!(flip, "xy"); - assert_eq!(align, "ctr"); - } - other => panic!("expected a tile, got {other:?}"), - } - assert!(picture(&s).upright); - s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap(); - match picture(&s).mode { - PictureMode::Tile { tx, align, .. } => { - assert_eq!(tx, 12.0); - assert_eq!(align, "ctr"); - } - other => panic!("expected the tile to stay, got {other:?}"), - } - assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step"); - - s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap(); - match picture(&s).mode { - PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]), - other => panic!("expected a stretch, got {other:?}"), - } - - s.execute("slide.new", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.slides[1].background.is_none()); - s.execute("slide.go", &json!({"index": 0})).unwrap(); - let n = undo_len(&s); - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background); - assert_eq!(undo_len(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background"); - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step"); - } - - #[test] - fn background_all_applies_to_master_and_new_slides_inherit() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); - assert_eq!(s0["background"], "#121826"); - - let n = undo_len(&s); - s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1); - - // Idempotency: re-applying the same background to all is a no-op (no extra undo step) - s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step"); - - // Slide 0 has new background, master has new background, layouts cleared - let doc = &s.doc().unwrap().doc; - assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. }))); - assert_eq!(doc.masters[0].background, doc.slides[0].background); - for l in &doc.masters[0].layouts { - assert!(l.background.is_none()); - } - - // Newly added slides inherit the master background (fixes #891) - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap(); - let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); - let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap(); - assert_eq!(s0_after["background"], "#0D0D0D"); - assert_eq!(s1_after["background"], "#0D0D0D"); - - // Undo restores previous slide and master backgrounds - s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new - assert_eq!(s.doc().unwrap().doc.slides.len(), 1); - s.execute("edit.undo", &json!({})).unwrap(); // undos design.background - let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); - assert_eq!(s0_reverted["background"], "#121826"); - } - - #[test] - fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); - - let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) }; - s.edit(|doc, _| { - let m = Arc::make_mut(&mut doc.masters[0]); - m.layouts[0].background = Some(custom_layout_bg.clone()); - Ok(()) - }) - .unwrap(); - - // Slide 0 follows master (background: None) - assert!(s.doc().unwrap().doc.slides[0].background.is_none()); - // Give Slide 1 an explicit override - s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap(); - assert!(s.doc().unwrap().doc.slides[1].background.is_some()); - - let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); - assert!(orig_master_bg.is_some()); - - // Select slide 0 (which follows master) and apply copy to all - s.execute("slide.go", &json!({"index": 0})).unwrap(); - let n = undo_len(&s); - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1); - - // Slide 1 has been reset to None (follows master), slide 0 remains None - let doc = &s.doc().unwrap().doc; - assert!(doc.slides[0].background.is_none()); - assert!(doc.slides[1].background.is_none()); - - // Master and layout backgrounds survived! - assert_eq!(doc.masters[0].background, orig_master_bg); - assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); - - // Idempotency: re-applying copy is a no-op - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op"); - } - - #[test] - fn background_reset_all_preserves_master_and_layout_backgrounds() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); - - let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) }; - s.edit(|doc, _| { - let m = Arc::make_mut(&mut doc.masters[0]); - m.layouts[0].background = Some(custom_layout_bg.clone()); - Ok(()) - }) - .unwrap(); - - let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); - assert!(orig_master_bg.is_some()); - - // Set explicit backgrounds on both slides individually (master remains untouched) - s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap(); - s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap(); - assert!(s.doc().unwrap().doc.slides[0].background.is_some()); - assert!(s.doc().unwrap().doc.slides[1].background.is_some()); - - // Now perform reset with all - let n = undo_len(&s); - s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1); - - // Slides reset to None to follow master - let doc = &s.doc().unwrap().doc; - assert!(doc.slides[0].background.is_none()); - assert!(doc.slides[1].background.is_none()); - - // Master and layout backgrounds survived! - assert_eq!(doc.masters[0].background, orig_master_bg); - assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); - - // Idempotency: re-running reset all is a no-op - s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op"); - } - - #[test] - fn background_all_only_touches_used_masters() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - - // Add a second master to presentation - let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) }; - s.edit(|doc, _| { - let mut m2 = (*doc.masters[0]).clone(); - m2.id = deckcraft_model::MasterId(999); - m2.background = Some(second_master_bg.clone()); - doc.masters.push(Arc::new(m2)); - Ok(()) - }) - .unwrap(); - - assert_eq!(s.doc().unwrap().doc.masters.len(), 2); - // Slides only use master 0 (default layout) - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - - let doc = &s.doc().unwrap().doc; - // Master 0 updated - assert_eq!(doc.masters[0].background, doc.slides[0].background); - // Master 1 preserved! - assert_eq!(doc.masters[1].background, Some(second_master_bg)); - } - - #[test] - fn background_all_clears_layout_overrides_even_if_slides_and_master_match() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - - // Set all slides and master to #AA0000 - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background); - - // Add a layout override on layout 0 - let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) }; - s.edit(|doc, _| { - let m = Arc::make_mut(&mut doc.masters[0]); - m.layouts[0].background = Some(custom_bg); - Ok(()) - }) - .unwrap(); - - // Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override. - let n = undo_len(&s); - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op"); - - let doc = &s.doc().unwrap().doc; - assert!(doc.masters[0].layouts[0].background.is_none()); - - // Now that layout override is cleared, repeating is an actual no-op - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); - } -} +mod tests; #[cfg(test)] mod browse_theme_tests { diff --git a/crates/engine/src/cmd/design/tests.rs b/crates/engine/src/cmd/design/tests.rs new file mode 100644 index 000000000..4d0b5e8a1 --- /dev/null +++ b/crates/engine/src/cmd/design/tests.rs @@ -0,0 +1,478 @@ +use std::sync::Arc; + +use deckcraft_color::SchemeSlot; +use deckcraft_model::style::PictureMode; +use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes}; +use deckcraft_model::{Background, Fill}; +use serde_json::{Value, json}; + +use crate::{Session, UiRequest}; + +fn scheme(s: &Session) -> deckcraft_color::ColorScheme { + s.doc().unwrap().doc.masters[0].theme.colors.clone() +} + +fn font_scheme(s: &Session) -> FontScheme { + s.doc().unwrap().doc.masters[0].theme.fonts.clone() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn png() -> String { + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255])); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + crate::cmd::base64_encode(&bytes) +} + +fn picture(s: &Session) -> deckcraft_model::style::PictureFill { + match &s.doc().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(), + other => panic!("expected a picture background, got {other:?}"), + } +} + +#[test] +fn layout_commands_reject_invalid_index() { + let mut s = Session::with_new(); + s.execute("master.insertLayout", &json!({})).unwrap(); + let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::(); + let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::>(); + let n = count(&s); + let before = names(&s); + for bad in [json!(-1), json!(1.5)] { + assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err()); + assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err()); + assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err()); + assert_eq!(count(&s), n); + assert_eq!(names(&s), before); + } + s.execute("master.deleteLayout", &json!({})).unwrap(); + assert_eq!(count(&s), n - 1); +} + +#[test] +fn civil_dates() { + assert_eq!(super::civil(0), (1970, 1, 1)); + assert_eq!(super::civil(20_734), (2026, 10, 8)); + // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. + assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); + assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); + assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); + assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); +} + +#[test] +fn colors_dialog_apply_and_reject() { + let mut s = Session::with_new(); + let original = scheme(&s); + let n = undo_len(&s); + s.execute("design.colors", &json!({})).unwrap(); + s.execute("design.colors", &Value::Null).unwrap(); + assert_eq!(scheme(&s), original); + assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); + assert_eq!(s.ui_requests.len(), 2); + match &s.ui_requests[0] { + UiRequest::Dialog { id, params } => { + assert_eq!(id, "themeColors"); + assert_eq!(params["name"], "DeckCraft"); + let colors = params["colors"].as_object().unwrap(); + assert_eq!(colors.len(), 12); + assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex())); + assert!(colors.contains_key("folHlink")); + } + other => panic!("expected a dialog, got {other:?}"), + } + + s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); + let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap(); + assert_eq!(scheme(&s).name, "Harbor"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); + s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); + + let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no colour scheme `Nope`"), "{err}"); + assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); + assert!(s.execute("design.colors", &json!({"name": 1})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); + assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); + assert_eq!(scheme(&s).name, "Harbor"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 1); + + s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); + assert_eq!(scheme(&s).name, "Custom"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); + assert_eq!(undo_len(&s), n + 2); + + let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); + s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); + assert_eq!(scheme(&s).name, "Meadow"); + assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 3); + + s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); + assert_eq!(scheme(&s).name, "Talk"); + assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); + assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(scheme(&s).name, "Meadow"); + assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); +} + +#[test] +fn fonts_dialog_apply_and_reject() { + let mut s = Session::with_new(); + let original = font_scheme(&s); + let n = undo_len(&s); + s.execute("design.fonts", &json!({})).unwrap(); + s.execute("design.fonts", &Value::Null).unwrap(); + s.execute("design.fonts", &json!([1, 2])).unwrap(); + assert_eq!(font_scheme(&s), original); + assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); + assert_eq!(s.ui_requests.len(), 3); + match &s.ui_requests[0] { + UiRequest::Dialog { id, params } => { + assert_eq!(id, "themeFonts"); + assert_eq!(params["name"], original.name); + assert_eq!(params["major"], original.major.latin); + assert_eq!(params["minor"], original.minor.latin); + } + other => panic!("expected a dialog, got {other:?}"), + } + + s.execute("design.fonts", &json!({"name": "classic"})).unwrap(); + let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap(); + assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name"); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); + s.execute("design.fonts", &json!({"name": "Classic"})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); + + let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no font scheme `Nope`"), "{err}"); + assert!(s.execute("design.fonts", &json!({"name": " "})).is_err()); + assert!(s.execute("design.fonts", &json!({"name": 1})).is_err()); + assert!(s.execute("design.fonts", &json!({"major": ""})).is_err()); + assert!(s.execute("design.fonts", &json!({"major": " "})).is_err()); + assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err()); + assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err()); + assert_eq!(font_scheme(&s), classic); + assert_eq!(undo_len(&s), n + 1); + + s.edit(|doc, _| { + let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts; + fonts.major.ea = "EA Major".into(); + fonts.minor.cs = "CS Minor".into(); + fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() }); + Ok(()) + }) + .unwrap(); + let marked = font_scheme(&s); + let n = undo_len(&s); + + s.execute("design.fonts", &json!({"major": " Lato "})).unwrap(); + let custom = font_scheme(&s); + assert_eq!(custom.name, "Custom"); + assert_eq!(custom.major.latin, "Lato"); + assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major"); + assert_eq!(custom.major.ea, "EA Major"); + assert_eq!(custom.minor.cs, "CS Minor"); + assert_eq!(custom.major.scripts, marked.major.scripts); + assert_eq!(undo_len(&s), n + 1); + + s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap(); + assert_eq!(font_scheme(&s).name, "Custom"); + assert_eq!(font_scheme(&s).major.latin, "Lato"); + assert_eq!(font_scheme(&s).minor.latin, "Roboto"); + assert_eq!(font_scheme(&s).major.ea, "EA Major"); + assert_eq!(undo_len(&s), n + 2); + + s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap(); + assert_eq!(font_scheme(&s).name, "Classic"); + assert_eq!(font_scheme(&s).major.latin, "Inter"); + assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); + assert_eq!(font_scheme(&s).major.ea, ""); + assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); + assert_eq!(undo_len(&s), n + 3); + + s.edit(|doc, _| { + std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into(); + Ok(()) + }) + .unwrap(); + s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap(); + assert_eq!(font_scheme(&s).name, "Talk"); + assert_eq!(font_scheme(&s).major.latin, "Inter"); + assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans"); + assert_eq!(font_scheme(&s).major.ea, "Kept"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(font_scheme(&s).name, "Classic"); + assert_eq!(font_scheme(&s).major.ea, "Kept"); + assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); +} + +#[test] +fn background_picture_options_tile_transparency_and_copy() { + let mut s = Session::with_new(); + let n = undo_len(&s); + let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string(); + assert!(err.contains("picture background"), "{err}"); + assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err()); + assert_eq!(undo_len(&s), n); + + s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap(); + match &s.doc().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()), + other => panic!("expected a solid background, got {other:?}"), + } + let n = undo_len(&s); + s.execute("design.background", &json!({"transparency": 0.25})).unwrap(); + assert_eq!(undo_len(&s), n, "the same transparency is not an undo step"); + + s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap(); + let pf = picture(&s); + assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6); + assert!(matches!(pf.mode, PictureMode::Tile { .. })); + let n = undo_len(&s); + + s.execute( + "design.background", + &json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}), + ) + .unwrap(); + match picture(&s).mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0)); + assert_eq!(flip, "xy"); + assert_eq!(align, "ctr"); + } + other => panic!("expected a tile, got {other:?}"), + } + assert!(picture(&s).upright); + s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap(); + match picture(&s).mode { + PictureMode::Tile { tx, align, .. } => { + assert_eq!(tx, 12.0); + assert_eq!(align, "ctr"); + } + other => panic!("expected the tile to stay, got {other:?}"), + } + assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step"); + + s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap(); + match picture(&s).mode { + PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]), + other => panic!("expected a stretch, got {other:?}"), + } + + s.execute("slide.new", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.slides[1].background.is_none()); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let n = undo_len(&s); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background"); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step"); +} + +#[test] +fn background_all_applies_to_master_and_new_slides_inherit() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); + assert_eq!(s0["background"], "#121826"); + + let n = undo_len(&s); + s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + + // Idempotency: re-applying the same background to all is a no-op (no extra undo step) + s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step"); + + // Slide 0 has new background, master has new background, layouts cleared + let doc = &s.doc().unwrap().doc; + assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. }))); + assert_eq!(doc.masters[0].background, doc.slides[0].background); + for l in &doc.masters[0].layouts { + assert!(l.background.is_none()); + } + + // Newly added slides inherit the master background (fixes #891) + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap(); + let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); + let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap(); + assert_eq!(s0_after["background"], "#0D0D0D"); + assert_eq!(s1_after["background"], "#0D0D0D"); + + // Undo restores previous slide and master backgrounds + s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new + assert_eq!(s.doc().unwrap().doc.slides.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); // undos design.background + let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); + assert_eq!(s0_reverted["background"], "#121826"); +} + +#[test] +fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); + + let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) }; + s.edit(|doc, _| { + let m = Arc::make_mut(&mut doc.masters[0]); + m.layouts[0].background = Some(custom_layout_bg.clone()); + Ok(()) + }) + .unwrap(); + + // Slide 0 follows master (background: None) + assert!(s.doc().unwrap().doc.slides[0].background.is_none()); + // Give Slide 1 an explicit override + s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap(); + assert!(s.doc().unwrap().doc.slides[1].background.is_some()); + + let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); + assert!(orig_master_bg.is_some()); + + // Select slide 0 (which follows master) and apply copy to all + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let n = undo_len(&s); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + + // Slide 1 has been reset to None (follows master), slide 0 remains None + let doc = &s.doc().unwrap().doc; + assert!(doc.slides[0].background.is_none()); + assert!(doc.slides[1].background.is_none()); + + // Master and layout backgrounds survived! + assert_eq!(doc.masters[0].background, orig_master_bg); + assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); + + // Idempotency: re-applying copy is a no-op + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op"); +} + +#[test] +fn background_reset_all_preserves_master_and_layout_backgrounds() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); + + let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) }; + s.edit(|doc, _| { + let m = Arc::make_mut(&mut doc.masters[0]); + m.layouts[0].background = Some(custom_layout_bg.clone()); + Ok(()) + }) + .unwrap(); + + let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); + assert!(orig_master_bg.is_some()); + + // Set explicit backgrounds on both slides individually (master remains untouched) + s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap(); + s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap(); + assert!(s.doc().unwrap().doc.slides[0].background.is_some()); + assert!(s.doc().unwrap().doc.slides[1].background.is_some()); + + // Now perform reset with all + let n = undo_len(&s); + s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + + // Slides reset to None to follow master + let doc = &s.doc().unwrap().doc; + assert!(doc.slides[0].background.is_none()); + assert!(doc.slides[1].background.is_none()); + + // Master and layout backgrounds survived! + assert_eq!(doc.masters[0].background, orig_master_bg); + assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); + + // Idempotency: re-running reset all is a no-op + s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op"); +} + +#[test] +fn background_all_only_touches_used_masters() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + + // Add a second master to presentation + let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) }; + s.edit(|doc, _| { + let mut m2 = (*doc.masters[0]).clone(); + m2.id = deckcraft_model::MasterId(999); + m2.background = Some(second_master_bg.clone()); + doc.masters.push(Arc::new(m2)); + Ok(()) + }) + .unwrap(); + + assert_eq!(s.doc().unwrap().doc.masters.len(), 2); + // Slides only use master 0 (default layout) + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + + let doc = &s.doc().unwrap().doc; + // Master 0 updated + assert_eq!(doc.masters[0].background, doc.slides[0].background); + // Master 1 preserved! + assert_eq!(doc.masters[1].background, Some(second_master_bg)); +} + +#[test] +fn background_all_clears_layout_overrides_even_if_slides_and_master_match() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + + // Set all slides and master to #AA0000 + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background); + + // Add a layout override on layout 0 + let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) }; + s.edit(|doc, _| { + let m = Arc::make_mut(&mut doc.masters[0]); + m.layouts[0].background = Some(custom_bg); + Ok(()) + }) + .unwrap(); + + // Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override. + let n = undo_len(&s); + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op"); + + let doc = &s.doc().unwrap().doc; + assert!(doc.masters[0].layouts[0].background.is_none()); + + // Now that layout override is cleared, repeating is an actual no-op + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); +} From b48467943babe8e960bd0a27c392d1be7b4beb50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:33 +0000 Subject: [PATCH 14/90] refactor(engine): move design browse_theme_tests to cmd/design/browse_theme_tests.rs --- crates/engine/src/cmd/design.rs | 283 +----------------- .../src/cmd/design/browse_theme_tests.rs | 279 +++++++++++++++++ 2 files changed, 280 insertions(+), 282 deletions(-) create mode 100644 crates/engine/src/cmd/design/browse_theme_tests.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index ece1f2017..520b7b6a2 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -1587,288 +1587,7 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph { mod tests; #[cfg(test)] -mod browse_theme_tests { - use std::sync::Arc; - - use deckcraft_model::text::RunKind; - use deckcraft_model::{Background, Fill, PhType}; - use serde_json::json; - - use crate::{Session, Target}; - - #[test] - fn applies_the_theme_of_another_deck_and_undoes() { - let mut other = Session::with_new(); - other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap(); - other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap(); - let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); - let before = s.doc().unwrap().doc.masters[0].theme.clone(); - s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); - let t = &s.doc().unwrap().doc.masters[0].theme; - assert_eq!(t.fonts.major.latin, "Lato"); - assert_eq!(t.fonts.minor.latin, "Source Sans 3"); - assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456"); - assert!(s.doc().unwrap().doc.validate().is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.masters[0].theme, before); - assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err()); - assert!(s.execute("design.browseTheme", &json!({})).is_err()); - } - - /// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides - /// can only find them by kind) and its content layout's title moved. - fn brand_template() -> (String, Vec, deckcraft_model::Xfrm) { - let mut img = image::RgbaImage::new(2, 2); - img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255])); - let mut logo = Vec::new(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap(); - let mut t = Session::with_new(); - t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap(); - let title = t - .edit(|doc, _| { - let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap(); - let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap()); - let m = Arc::make_mut(doc.masters.get_mut(0).unwrap()); - m.shapes.push(deckcraft_model::Shape { - id, - name: "Logo".into(), - kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, - xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }), - ..Default::default() - }); - m.background = - Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) }); - for l in &mut m.layouts { - l.name = format!("Brand {}", l.name); - } - let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap(); - let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); - let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() }; - title.xfrm = Some(x); - Ok(x) - }) - .unwrap(); - (crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title) - } - - #[test] - fn a_template_brings_its_master_and_layouts_and_slides_follow() { - let (data, logo, title) = brand_template(); - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let n = undo_len(&s); - let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap(); - assert_eq!(r["slides"], json!(2), "{r}"); - assert_eq!(undo_len(&s), n + 1, "one undo step"); - - let doc = &s.doc().unwrap().doc; - assert!(doc.validate().is_empty(), "{:?}", doc.validate()); - assert_eq!(doc.masters.len(), 1, "the old master is replaced"); - let m = &doc.masters[0]; - assert_ne!(m.id, before.masters[0].id); - assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand "))); - assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } }))); - let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along"); - let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") }; - assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo)); - // The 4:3 master stretches to this deck's 16:9. - let k = doc.slide_size.width / 720.0; - let px = pic.xfrm.unwrap(); - assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}"); - - // Each slide moved to the brand layout of its kind, and kept its text. - for (old, new) in before.slides.iter().zip(&doc.slides) { - let (_, was) = before.layout(old.layout).unwrap(); - let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master"); - assert_eq!(owner.id, m.id); - assert_eq!(now.kind, was.kind); - } - let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap(); - assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one"))); - let (_, lay) = doc.layout(agenda.layout).unwrap(); - let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap(); - assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}"); - let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); - assert!(st.xfrm.is_none(), "the slide title follows the new layout"); - - s.execute("edit.undo", &json!({})).unwrap(); - let doc = &s.doc().unwrap().doc; - assert_eq!(doc.masters, before.masters); - assert_eq!(doc.slides, before.slides); - } - - #[test] - fn a_preserved_master_stays_beside_the_template() { - let (data, _, _) = brand_template(); - let mut s = Session::with_new(); - s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); - s.execute("master.preserve", &json!({"preserve": true})).unwrap(); - s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); - s.execute("master.preserve", &json!({"preserve": true})).unwrap(); - let old = s.doc().unwrap().doc.masters[0].id; - s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); - s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); - let st = s.doc().unwrap(); - assert_eq!(st.doc.masters.len(), 2); - assert_eq!(st.doc.masters[1].id, old); - assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master"); - let new = &st.doc.masters[0]; - assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some())); - } - - fn undo_len(s: &crate::Session) -> usize { - s.doc().unwrap().history.undo.len() - } - - #[test] - fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() { - let mut s = crate::Session::with_new(); - let listed = s.execute("design.themes", &json!({})).unwrap(); - let names: Vec = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); - assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]); - - let n = undo_len(&s); - s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap(); - assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step"); - let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string(); - assert!(err.contains("no effect scheme"), "{err}"); - assert!(s.execute("design.effects", &json!({})).is_err()); - assert_eq!(undo_len(&s), n); - - s.execute("design.theme", &json!({"name": "Harbor"})).unwrap(); - let before = s.doc().unwrap().doc.masters[0].theme.clone(); - s.execute("design.effects", &json!({"name": "soft"})).unwrap(); - let theme = s.doc().unwrap().doc.masters[0].theme.clone(); - assert_eq!(theme.name, "Harbor"); - assert_eq!(theme.colors, before.colors); - assert_eq!(theme.fonts, before.fonts); - assert_eq!(theme.format.name, "Soft"); - assert!(theme.format.effects[0].outer_shadow.is_some()); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects"); - - let n = undo_len(&s); - s.execute("design.effects", &json!({"name": "Soft"})).unwrap(); - assert_eq!(undo_len(&s), n, "the same scheme is not an undo step"); - - s.execute("design.effects", &json!({"name": "Glow"})).unwrap(); - let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone(); - assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6)); - s.execute("design.effects", &json!({"name": "Plain"})).unwrap(); - let plain = s.doc().unwrap().doc.masters[0].theme.format.clone(); - assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none())); - assert_eq!(plain.lines[2].width, Some(0.75)); - } - - fn shapes(s: &Session) -> Vec { - s.doc().unwrap().shapes().to_vec() - } - - fn has_title(s: &Session) -> bool { - shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)) - } - - fn has_footers(s: &Session) -> bool { - shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)) - } - - #[test] - fn preserve_and_placeholders_edit_the_open_master_or_layout() { - for id in ["master.preserve", "master.placeholders"] { - assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}"); - } - - let mut s = Session::with_new(); - let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); - - s.execute("view.slideMaster", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 }); - s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 }); - assert!(!s.doc().unwrap().doc.masters[0].preserve); - - let n = s.doc().unwrap().history.undo.len(); - s.execute("master.preserve", &json!({"preserve": false})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step"); - s.execute("master.preserve", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.masters[0].preserve); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!s.doc().unwrap().doc.masters[0].preserve); - - s.execute("master.placeholders", &json!({"footers": false})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); - assert!(has_title(&s)); - assert!(!has_footers(&s)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(has_footers(&s)); - - s.execute("view.closeMaster", &json!({})).unwrap(); - let n = s.doc().unwrap().history.undo.len(); - let err = s.execute("master.preserve", &json!({})).unwrap_err(); - assert!(err.to_string().contains("open Slide Master view"), "{err}"); - let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err(); - assert!(err.to_string().contains("open Slide Master view"), "{err}"); - let err = s.execute("master.placeholders", &json!({})).unwrap_err(); - assert!(err.to_string().contains("give title or footers"), "{err}"); - assert_eq!(s.doc().unwrap().history.undo.len(), n); - - s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); - assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); - assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - assert!(!has_title(&s)); - assert!(has_footers(&s)); - - let n = s.doc().unwrap().history.undo.len(); - s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state"); - - s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); - assert!(has_title(&s)); - assert!(!has_footers(&s)); - let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); - assert_eq!(title.name, "Title 1"); - assert_eq!(title.ph.unwrap().idx, 0); - assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!has_title(&s)); - assert!(has_footers(&s)); - - s.execute("master.placeholders", &json!({"footers": false})).unwrap(); - s.execute("master.placeholders", &json!({"footers": true})).unwrap(); - let restored = shapes(&s); - assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); - assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); - let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); - let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0]; - assert_eq!(run.text, "‹#›"); - assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum")); - - assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - s.execute("master.preserve", &json!({"preserve": false})).unwrap(); - assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - - s.edit(|doc, _| { - let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); - m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); - Ok(()) - }) - .unwrap(); - s.execute("master.placeholders", &json!({"title": true})).unwrap(); - let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); - assert_eq!(title.name, "Title"); - let box_ = title.xfrm.unwrap(); - assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); - assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); - } -} +mod browse_theme_tests; #[cfg(test)] mod header_footer_date_tests { diff --git a/crates/engine/src/cmd/design/browse_theme_tests.rs b/crates/engine/src/cmd/design/browse_theme_tests.rs new file mode 100644 index 000000000..891362692 --- /dev/null +++ b/crates/engine/src/cmd/design/browse_theme_tests.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Background, Fill, PhType}; +use serde_json::json; + +use crate::{Session, Target}; + +#[test] +fn applies_the_theme_of_another_deck_and_undoes() { + let mut other = Session::with_new(); + other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap(); + other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let before = s.doc().unwrap().doc.masters[0].theme.clone(); + s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); + let t = &s.doc().unwrap().doc.masters[0].theme; + assert_eq!(t.fonts.major.latin, "Lato"); + assert_eq!(t.fonts.minor.latin, "Source Sans 3"); + assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme, before); + assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err()); + assert!(s.execute("design.browseTheme", &json!({})).is_err()); +} + +/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides +/// can only find them by kind) and its content layout's title moved. +fn brand_template() -> (String, Vec, deckcraft_model::Xfrm) { + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255])); + let mut logo = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap(); + let mut t = Session::with_new(); + t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap(); + let title = t + .edit(|doc, _| { + let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap(); + let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap()); + let m = Arc::make_mut(doc.masters.get_mut(0).unwrap()); + m.shapes.push(deckcraft_model::Shape { + id, + name: "Logo".into(), + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }), + ..Default::default() + }); + m.background = Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) }); + for l in &mut m.layouts { + l.name = format!("Brand {}", l.name); + } + let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap(); + let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() }; + title.xfrm = Some(x); + Ok(x) + }) + .unwrap(); + (crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title) +} + +#[test] +fn a_template_brings_its_master_and_layouts_and_slides_follow() { + let (data, logo, title) = brand_template(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let n = undo_len(&s); + let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap(); + assert_eq!(r["slides"], json!(2), "{r}"); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + + let doc = &s.doc().unwrap().doc; + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert_eq!(doc.masters.len(), 1, "the old master is replaced"); + let m = &doc.masters[0]; + assert_ne!(m.id, before.masters[0].id); + assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand "))); + assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } }))); + let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along"); + let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") }; + assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo)); + // The 4:3 master stretches to this deck's 16:9. + let k = doc.slide_size.width / 720.0; + let px = pic.xfrm.unwrap(); + assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}"); + + // Each slide moved to the brand layout of its kind, and kept its text. + for (old, new) in before.slides.iter().zip(&doc.slides) { + let (_, was) = before.layout(old.layout).unwrap(); + let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master"); + assert_eq!(owner.id, m.id); + assert_eq!(now.kind, was.kind); + } + let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap(); + assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one"))); + let (_, lay) = doc.layout(agenda.layout).unwrap(); + let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap(); + assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}"); + let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert!(st.xfrm.is_none(), "the slide title follows the new layout"); + + s.execute("edit.undo", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.masters, before.masters); + assert_eq!(doc.slides, before.slides); +} + +#[test] +fn a_preserved_master_stays_beside_the_template() { + let (data, _, _) = brand_template(); + let mut s = Session::with_new(); + s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); + s.execute("master.preserve", &json!({"preserve": true})).unwrap(); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + s.execute("master.preserve", &json!({"preserve": true})).unwrap(); + let old = s.doc().unwrap().doc.masters[0].id; + s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); + s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.masters.len(), 2); + assert_eq!(st.doc.masters[1].id, old); + assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master"); + let new = &st.doc.masters[0]; + assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some())); +} + +fn undo_len(s: &crate::Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() { + let mut s = crate::Session::with_new(); + let listed = s.execute("design.themes", &json!({})).unwrap(); + let names: Vec = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]); + + let n = undo_len(&s); + s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap(); + assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step"); + let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string(); + assert!(err.contains("no effect scheme"), "{err}"); + assert!(s.execute("design.effects", &json!({})).is_err()); + assert_eq!(undo_len(&s), n); + + s.execute("design.theme", &json!({"name": "Harbor"})).unwrap(); + let before = s.doc().unwrap().doc.masters[0].theme.clone(); + s.execute("design.effects", &json!({"name": "soft"})).unwrap(); + let theme = s.doc().unwrap().doc.masters[0].theme.clone(); + assert_eq!(theme.name, "Harbor"); + assert_eq!(theme.colors, before.colors); + assert_eq!(theme.fonts, before.fonts); + assert_eq!(theme.format.name, "Soft"); + assert!(theme.format.effects[0].outer_shadow.is_some()); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects"); + + let n = undo_len(&s); + s.execute("design.effects", &json!({"name": "Soft"})).unwrap(); + assert_eq!(undo_len(&s), n, "the same scheme is not an undo step"); + + s.execute("design.effects", &json!({"name": "Glow"})).unwrap(); + let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone(); + assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6)); + s.execute("design.effects", &json!({"name": "Plain"})).unwrap(); + let plain = s.doc().unwrap().doc.masters[0].theme.format.clone(); + assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none())); + assert_eq!(plain.lines[2].width, Some(0.75)); +} + +fn shapes(s: &Session) -> Vec { + s.doc().unwrap().shapes().to_vec() +} + +fn has_title(s: &Session) -> bool { + shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)) +} + +fn has_footers(s: &Session) -> bool { + shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)) +} + +#[test] +fn preserve_and_placeholders_edit_the_open_master_or_layout() { + for id in ["master.preserve", "master.placeholders"] { + assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}"); + } + + let mut s = Session::with_new(); + let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); + + s.execute("view.slideMaster", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 }); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 }); + assert!(!s.doc().unwrap().doc.masters[0].preserve); + + let n = s.doc().unwrap().history.undo.len(); + s.execute("master.preserve", &json!({"preserve": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step"); + s.execute("master.preserve", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.masters[0].preserve); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().doc.masters[0].preserve); + + s.execute("master.placeholders", &json!({"footers": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); + assert!(has_title(&s)); + assert!(!has_footers(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(has_footers(&s)); + + s.execute("view.closeMaster", &json!({})).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("master.preserve", &json!({})).unwrap_err(); + assert!(err.to_string().contains("open Slide Master view"), "{err}"); + let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err(); + assert!(err.to_string().contains("open Slide Master view"), "{err}"); + let err = s.execute("master.placeholders", &json!({})).unwrap_err(); + assert!(err.to_string().contains("give title or footers"), "{err}"); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + + s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + assert!(!has_title(&s)); + assert!(has_footers(&s)); + + let n = s.doc().unwrap().history.undo.len(); + s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state"); + + s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); + assert!(has_title(&s)); + assert!(!has_footers(&s)); + let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert_eq!(title.name, "Title 1"); + assert_eq!(title.ph.unwrap().idx, 0); + assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!has_title(&s)); + assert!(has_footers(&s)); + + s.execute("master.placeholders", &json!({"footers": false})).unwrap(); + s.execute("master.placeholders", &json!({"footers": true})).unwrap(); + let restored = shapes(&s); + assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); + assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); + let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); + let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0]; + assert_eq!(run.text, "‹#›"); + assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum")); + + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + s.execute("master.preserve", &json!({"preserve": false})).unwrap(); + assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + + s.edit(|doc, _| { + let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); + m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + Ok(()) + }) + .unwrap(); + s.execute("master.placeholders", &json!({"title": true})).unwrap(); + let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); + assert_eq!(title.name, "Title"); + let box_ = title.xfrm.unwrap(); + assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); + assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); +} From e9a7413c87e29f2b6b95fe6b1670e9b33013fca8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:34 +0000 Subject: [PATCH 15/90] refactor(engine): move insert icon_and_picture_tests to cmd/insert/icon_and_picture_tests.rs --- crates/engine/src/cmd/insert.rs | 287 +----------------- .../src/cmd/insert/icon_and_picture_tests.rs | 284 +++++++++++++++++ 2 files changed, 285 insertions(+), 286 deletions(-) create mode 100644 crates/engine/src/cmd/insert/icon_and_picture_tests.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index f2a2b9bda..81b3426a3 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -2019,289 +2019,4 @@ mod tests { } #[cfg(test)] -mod icon_and_picture_tests { - use deckcraft_model::ShapeKind; - use deckcraft_model::style::LineJoin; - - use super::*; - - /// 8×8 red PNG. - fn png() -> Vec { - vec![ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, - 68, 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, - 66, 96, 130, - ] - } - - fn pic(name: &str) -> Value { - json!({"name": name, "data": base64_encode(&png())}) - } - - fn n_slides(s: &Session) -> usize { - s.active().map(|d| d.doc.slides.len()).unwrap_or(0) - } - - fn pictures(s: &Session, index: usize) -> Vec { - s.active() - .and_then(|d| d.doc.slides.get(index)) - .map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect()) - .unwrap_or_default() - } - - fn box_text(s: &Session, index: usize) -> Vec { - s.active() - .and_then(|d| d.doc.slides.get(index)) - .map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect()) - .unwrap_or_default() - } - - #[test] - fn two_pictures_one_per_slide_are_one_undo() { - let mut s = Session::with_new(); - let before = n_slides(&s); - let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album"); - assert_eq!(r["slides"], 2); - assert_eq!(n_slides(&s), before + 2); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(pictures(&s, 2).len(), 1); - assert_eq!(s.active().unwrap().selection.slide, 1); - let undo = &s.active().unwrap().history.undo; - assert_eq!(undo.len(), 1); - assert_eq!(undo[0].label, "Photo Album…"); - s.execute("edit.undo", &json!({})).expect("undo"); - assert_eq!(n_slides(&s), before); - assert!(pictures(&s, 1).is_empty()); - } - - #[test] - fn two_up_shares_one_slide() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album"); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 2); - assert!(box_text(&s, 1).is_empty()); - } - - #[test] - fn captions_and_titles_use_the_file_stem() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions"); - assert_eq!(box_text(&s, 1), ["harbor"]); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title"); - assert_eq!(box_text(&s, 1), ["harbor"]); - assert_eq!(pictures(&s, 1).len(), 1); - let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y; - assert!(pic_y > 40.0, "the picture sits below the title"); - } - - #[test] - fn frames_set_the_picture_line() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect"); - let line = pictures(&s, 1)[0].line.clone().expect("line"); - assert_eq!(line.width, Some(1.5)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded"); - let line = pictures(&s, 1)[0].line.clone().expect("line"); - assert_eq!(line.width, Some(2.25)); - assert_eq!(line.join, Some(LineJoin::Round)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple"); - assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0)); - } - - #[test] - fn a_text_box_breaks_the_picture_row() { - let mut s = Session::with_new(); - let r = s - .execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"})) - .expect("album"); - assert_eq!(r["slides"], 3); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(box_text(&s, 2), ["A day on the water"]); - assert!(pictures(&s, 2).is_empty()); - assert_eq!(pictures(&s, 3).len(), 1); - assert_eq!(s.active().unwrap().history.undo.len(), 1); - } - - #[test] - fn no_pictures_opens_the_dialog_without_editing() { - let mut s = Session::with_new(); - let before = n_slides(&s); - let r = s.execute("insert.photoAlbum", &json!({})).expect("open"); - assert_eq!(r["dialog"], "photoAlbum"); - assert_eq!(n_slides(&s), before); - assert!(s.active().unwrap().history.undo.is_empty()); - assert_eq!(s.ui_requests.len(), 1); - match &s.ui_requests[0] { - crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"), - other => panic!("dialog request, got {other:?}"), - } - } - - #[test] - fn bad_input_adds_no_slides() { - let mut s = Session::with_new(); - let before = n_slides(&s); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err()); - let mut many = Vec::new(); - for i in 0..49 { - many.push(json!({"text": format!("box {i}")})); - } - assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err()); - assert_eq!(n_slides(&s), before); - assert!(s.active().unwrap().history.undo.is_empty()); - } - - #[test] - fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap(); - let one = pictures(&s, 1)[0].xfrm.unwrap().h; - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap(); - let fitted = pictures(&s, 1)[0].xfrm.unwrap().h; - assert!(fitted > one, "fit {fitted} should be taller than one-up {one}"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap(); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 4); - } - - fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust { - match &pictures(s, index)[0].kind { - ShapeKind::Picture { fill } => fill.adjust.clone(), - _ => panic!("picture"), - } - } - - fn theme_name(s: &Session) -> String { - s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default() - } - - #[test] - fn edit_without_pictures_reopens_the_dialog() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); - s.ui_requests.clear(); - let before = n_slides(&s); - let undos = s.active().unwrap().history.undo.len(); - let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen"); - assert_eq!(r["dialog"], "photoAlbum"); - assert_eq!(n_slides(&s), before); - assert_eq!(s.active().unwrap().history.undo.len(), undos); - let params = match &s.ui_requests[0] { - crate::UiRequest::Dialog { id, params } => { - assert_eq!(id, "photoAlbum"); - params - } - other => panic!("dialog request, got {other:?}"), - }; - assert_eq!(params["items"][0]["name"], "hill.png"); - assert_eq!(params["items"][1]["name"], "lake.png"); - assert_eq!(params["layout"], "1"); - assert!(params["album"].as_u64().unwrap_or(0) > 0); - } - - #[test] - fn edit_reflows_the_album_in_one_undo() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); - assert_eq!(n_slides(&s), 3); - let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow"); - assert_eq!(r["slides"], 1); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 2); - let undo = &s.active().unwrap().history.undo; - assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…")); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(n_slides(&s), 3); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(pictures(&s, 2).len(), 1); - } - - #[test] - fn picture_options_and_theme_are_one_step() { - let mut s = Session::with_new(); - s.execute( - "insert.photoAlbum", - &json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}), - ) - .unwrap(); - let adj = adjust(&s, 1); - assert!(adj.grayscale); - assert!((adj.brightness - 0.2).abs() < 1e-6); - assert!((adj.contrast + 0.1).abs() < 1e-6); - assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01); - assert_eq!(theme_name(&s), "Harbor"); - let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); - assert!(mark.contains("gray=1"), "{mark}"); - assert!(mark.contains("theme=Harbor"), "{mark}"); - assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(theme_name(&s), "DeckCraft"); - assert_eq!(n_slides(&s), 1); - } - - #[test] - fn unknown_theme_and_a_plain_slide_change_nothing() { - let mut s = Session::with_new(); - let before = n_slides(&s); - let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err(); - assert!(err.to_string().contains("unknown theme"), "{err}"); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err()); - let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err(); - assert!(err.to_string().contains("not part of a photo album"), "{err}"); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap(); - s.execute("slide.go", &json!({"index": 0})).unwrap(); - assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err()); - assert_eq!(n_slides(&s), before + 1); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(theme_name(&s), "DeckCraft"); - } - - #[test] - fn two_albums_stay_independent() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); - let last = n_slides(&s) - 1; - s.execute("slide.go", &json!({"index": last})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap(); - assert_eq!(n_slides(&s), 5); - s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap(); - assert_eq!(n_slides(&s), 4); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(pictures(&s, 2).len(), 1); - assert_eq!(pictures(&s, 3).len(), 2); - let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); - let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default(); - assert!(first.starts_with("deckcraft-album:")); - assert!(second.starts_with("deckcraft-album:")); - assert_ne!(first.split(';').next(), second.split(';').next()); - } - - #[test] - fn a_named_run_without_a_marker_still_reflows() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); - { - let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); - for sl in doc.slides.iter_mut().skip(1) { - std::sync::Arc::make_mut(sl).raw_ext = None; - } - } - s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap(); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 2); - let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); - assert!(mark.starts_with("deckcraft-album:"), "{mark}"); - } -} +mod icon_and_picture_tests; diff --git a/crates/engine/src/cmd/insert/icon_and_picture_tests.rs b/crates/engine/src/cmd/insert/icon_and_picture_tests.rs new file mode 100644 index 000000000..f9b06f343 --- /dev/null +++ b/crates/engine/src/cmd/insert/icon_and_picture_tests.rs @@ -0,0 +1,284 @@ +use deckcraft_model::ShapeKind; +use deckcraft_model::style::LineJoin; + +use super::*; + +/// 8×8 red PNG. +fn png() -> Vec { + vec![ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68, + 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, + 130, + ] +} + +fn pic(name: &str) -> Value { + json!({"name": name, "data": base64_encode(&png())}) +} + +fn n_slides(s: &Session) -> usize { + s.active().map(|d| d.doc.slides.len()).unwrap_or(0) +} + +fn pictures(s: &Session, index: usize) -> Vec { + s.active() + .and_then(|d| d.doc.slides.get(index)) + .map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect()) + .unwrap_or_default() +} + +fn box_text(s: &Session, index: usize) -> Vec { + s.active() + .and_then(|d| d.doc.slides.get(index)) + .map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect()) + .unwrap_or_default() +} + +#[test] +fn two_pictures_one_per_slide_are_one_undo() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album"); + assert_eq!(r["slides"], 2); + assert_eq!(n_slides(&s), before + 2); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); + assert_eq!(s.active().unwrap().selection.slide, 1); + let undo = &s.active().unwrap().history.undo; + assert_eq!(undo.len(), 1); + assert_eq!(undo[0].label, "Photo Album…"); + s.execute("edit.undo", &json!({})).expect("undo"); + assert_eq!(n_slides(&s), before); + assert!(pictures(&s, 1).is_empty()); +} + +#[test] +fn two_up_shares_one_slide() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album"); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + assert!(box_text(&s, 1).is_empty()); +} + +#[test] +fn captions_and_titles_use_the_file_stem() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions"); + assert_eq!(box_text(&s, 1), ["harbor"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title"); + assert_eq!(box_text(&s, 1), ["harbor"]); + assert_eq!(pictures(&s, 1).len(), 1); + let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y; + assert!(pic_y > 40.0, "the picture sits below the title"); +} + +#[test] +fn frames_set_the_picture_line() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect"); + let line = pictures(&s, 1)[0].line.clone().expect("line"); + assert_eq!(line.width, Some(1.5)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded"); + let line = pictures(&s, 1)[0].line.clone().expect("line"); + assert_eq!(line.width, Some(2.25)); + assert_eq!(line.join, Some(LineJoin::Round)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple"); + assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0)); +} + +#[test] +fn a_text_box_breaks_the_picture_row() { + let mut s = Session::with_new(); + let r = s + .execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"})) + .expect("album"); + assert_eq!(r["slides"], 3); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(box_text(&s, 2), ["A day on the water"]); + assert!(pictures(&s, 2).is_empty()); + assert_eq!(pictures(&s, 3).len(), 1); + assert_eq!(s.active().unwrap().history.undo.len(), 1); +} + +#[test] +fn no_pictures_opens_the_dialog_without_editing() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let r = s.execute("insert.photoAlbum", &json!({})).expect("open"); + assert_eq!(r["dialog"], "photoAlbum"); + assert_eq!(n_slides(&s), before); + assert!(s.active().unwrap().history.undo.is_empty()); + assert_eq!(s.ui_requests.len(), 1); + match &s.ui_requests[0] { + crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"), + other => panic!("dialog request, got {other:?}"), + } +} + +#[test] +fn bad_input_adds_no_slides() { + let mut s = Session::with_new(); + let before = n_slides(&s); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err()); + let mut many = Vec::new(); + for i in 0..49 { + many.push(json!({"text": format!("box {i}")})); + } + assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err()); + assert_eq!(n_slides(&s), before); + assert!(s.active().unwrap().history.undo.is_empty()); +} + +#[test] +fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap(); + let one = pictures(&s, 1)[0].xfrm.unwrap().h; + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap(); + let fitted = pictures(&s, 1)[0].xfrm.unwrap().h; + assert!(fitted > one, "fit {fitted} should be taller than one-up {one}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap(); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 4); +} + +fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust { + match &pictures(s, index)[0].kind { + ShapeKind::Picture { fill } => fill.adjust.clone(), + _ => panic!("picture"), + } +} + +fn theme_name(s: &Session) -> String { + s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default() +} + +#[test] +fn edit_without_pictures_reopens_the_dialog() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); + s.ui_requests.clear(); + let before = n_slides(&s); + let undos = s.active().unwrap().history.undo.len(); + let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen"); + assert_eq!(r["dialog"], "photoAlbum"); + assert_eq!(n_slides(&s), before); + assert_eq!(s.active().unwrap().history.undo.len(), undos); + let params = match &s.ui_requests[0] { + crate::UiRequest::Dialog { id, params } => { + assert_eq!(id, "photoAlbum"); + params + } + other => panic!("dialog request, got {other:?}"), + }; + assert_eq!(params["items"][0]["name"], "hill.png"); + assert_eq!(params["items"][1]["name"], "lake.png"); + assert_eq!(params["layout"], "1"); + assert!(params["album"].as_u64().unwrap_or(0) > 0); +} + +#[test] +fn edit_reflows_the_album_in_one_undo() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); + assert_eq!(n_slides(&s), 3); + let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow"); + assert_eq!(r["slides"], 1); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + let undo = &s.active().unwrap().history.undo; + assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(n_slides(&s), 3); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); +} + +#[test] +fn picture_options_and_theme_are_one_step() { + let mut s = Session::with_new(); + s.execute( + "insert.photoAlbum", + &json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}), + ) + .unwrap(); + let adj = adjust(&s, 1); + assert!(adj.grayscale); + assert!((adj.brightness - 0.2).abs() < 1e-6); + assert!((adj.contrast + 0.1).abs() < 1e-6); + assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01); + assert_eq!(theme_name(&s), "Harbor"); + let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); + assert!(mark.contains("gray=1"), "{mark}"); + assert!(mark.contains("theme=Harbor"), "{mark}"); + assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(theme_name(&s), "DeckCraft"); + assert_eq!(n_slides(&s), 1); +} + +#[test] +fn unknown_theme_and_a_plain_slide_change_nothing() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err(); + assert!(err.to_string().contains("unknown theme"), "{err}"); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err()); + let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err(); + assert!(err.to_string().contains("not part of a photo album"), "{err}"); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err()); + assert_eq!(n_slides(&s), before + 1); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(theme_name(&s), "DeckCraft"); +} + +#[test] +fn two_albums_stay_independent() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); + let last = n_slides(&s) - 1; + s.execute("slide.go", &json!({"index": last})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap(); + assert_eq!(n_slides(&s), 5); + s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap(); + assert_eq!(n_slides(&s), 4); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); + assert_eq!(pictures(&s, 3).len(), 2); + let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); + let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default(); + assert!(first.starts_with("deckcraft-album:")); + assert!(second.starts_with("deckcraft-album:")); + assert_ne!(first.split(';').next(), second.split(';').next()); +} + +#[test] +fn a_named_run_without_a_marker_still_reflows() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); + { + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + for sl in doc.slides.iter_mut().skip(1) { + std::sync::Arc::make_mut(sl).raw_ext = None; + } + } + s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap(); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); + assert!(mark.starts_with("deckcraft-album:"), "{mark}"); +} From 50feadc965e2a5d1f4343bc0288675b225e3a832 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:34 +0000 Subject: [PATCH 16/90] refactor(deckcraft): move control_server tests to control_server/tests.rs --- apps/deckcraft/src/control_server.rs | 302 +-------------------- apps/deckcraft/src/control_server/tests.rs | 299 ++++++++++++++++++++ 2 files changed, 300 insertions(+), 301 deletions(-) create mode 100644 apps/deckcraft/src/control_server/tests.rs diff --git a/apps/deckcraft/src/control_server.rs b/apps/deckcraft/src/control_server.rs index 6088f343d..033ae4a4f 100644 --- a/apps/deckcraft/src/control_server.rs +++ b/apps/deckcraft/src/control_server.rs @@ -307,304 +307,4 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context } #[cfg(test)] -mod tests { - use super::*; - - /// A failed accept is followed by a wait before the next one, so a listener that keeps - /// failing does not spin, and the connections around the failures still come through. - #[test] - fn a_failed_accept_waits_before_the_next() { - let failed = || Err(std::io::Error::other("no file descriptors left")); - let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; - let mut waits = 0; - let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); - assert_eq!(connections, [1, 2]); - assert_eq!(waits, 3, "one wait per failed accept"); - } - use std::net::SocketAddr; - use std::time::Instant; - - fn small() -> Limits { - Limits { - max_clients: 2, - max_line: 64, - queue: 1, - idle_timeout: Duration::from_millis(300), - write_timeout: Duration::from_millis(300), - reply_timeout: Duration::from_millis(300), - } - } - - fn server(limits: Limits) -> (SocketAddr, Receiver) { - let l = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = l.local_addr().unwrap(); - (addr, serve_listener(l, limits, egui::Context::default())) - } - - fn client(addr: SocketAddr) -> (BufReader, TcpStream) { - let s = TcpStream::connect(addr).unwrap(); - s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - (BufReader::new(s.try_clone().unwrap()), s) - } - - fn reply(r: &mut BufReader) -> Option { - let mut s = String::new(); - match r.read_line(&mut s) { - Ok(0) | Err(_) => None, - Ok(_) => serde_json::from_str(&s).ok(), - } - } - - /// Answer every request with its method name until the server goes away. - fn ui(rx: Receiver) { - std::thread::spawn(move || { - for req in rx { - let _ = req.reply.send(json!({"ok": true, "result": req.method})); - } - }); - } - - #[test] - fn request_round_trip() { - let (addr, rx) = server(small()); - ui(rx); - let (mut r, mut w) = client(addr); - writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap(); - let v = reply(&mut r).unwrap(); - assert_eq!(v["id"], 7); - assert_eq!(v["result"], "ui.inspect"); - } - - /// Every reply line until the server closes the connection. - fn replies_until_closed(r: &mut BufReader) -> Vec { - std::iter::from_fn(|| reply(r)).collect() - } - - /// A browser can POST to the port: its request line closes the connection before the JSON - /// line in its body is read, so nothing is dispatched. - #[test] - fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { - let (addr, rx) = server(Limits { max_line: 512, ..small() }); - let (mut r, mut w) = client(addr); - w.write_all( - b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\ - {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n", - ) - .unwrap(); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); - assert!(rx.try_recv().is_err(), "the body was dispatched"); - } - - #[test] - fn a_line_that_is_not_a_request_closes_the_connection() { - let (addr, rx) = server(small()); - ui(rx); - for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { - let (mut r, mut w) = client(addr); - w.write_all(junk).unwrap(); - w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert_eq!(replies[0]["ok"], false); - } - assert!(is_http_request_line("OPTIONS * HTTP/1.1")); - assert!(is_http_request_line("PRI * HTTP/2.0")); - assert!(!is_http_request_line("{\"method\":\"GET /\"}")); - } - - /// A peer that pipelines -- two lines in one write, as a script does -- still reads why its - /// line was rejected. Closing with its second line unread resets the connection and discards - /// the reply already on its way, so the peer saw nothing at all; repeated here because it - /// depended on the timing of the two. - #[test] - fn a_pipelined_peer_still_reads_why_its_line_was_rejected() { - // A slot comes back only when the thread serving a peer ends, which can be after the next - // peer connects, so every peer has a slot of its own. - const PEERS: usize = 12; - let (addr, rx) = server(Limits { max_clients: PEERS, ..small() }); - for _ in 0..PEERS { - let (mut r, mut w) = client(addr); - w.write_all(b"hello\n").unwrap(); - // A second line, written after the first as a script writes it: it races the close. - let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert_eq!(replies[0]["ok"], false); - assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}"); - } - // The line after the rejected one is never dispatched, pipelined or not. - assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched"); - } - - /// A peer refused because every connection is taken still reads why when it had already - /// written a line: closing with that line unread resets the connection and discards the - /// reply. Repeated because it depends on the timing of the two. - #[test] - fn a_pipelined_peer_refused_as_busy_still_reads_why() { - let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - // Take the only slot, and make sure it is being served. - let (mut hr, mut hw) = client(addr); - writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - assert!(reply(&mut hr).is_some()); - for _ in 0..50 { - let (mut r, mut w) = client(addr); - let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}"); - } - } - - /// Peers refused as busy that stay connected without sending anything do not hold up the - /// connections after them: each one's drain runs to its deadline on a thread of its own, and - /// past `MAX_REFUSING` drains at once a refused connection is closed without one. - #[test] - fn silent_refused_peers_do_not_delay_the_next_refusal() { - let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - let (mut hr, mut hw) = client(addr); - writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - assert!(reply(&mut hr).is_some()); - // Twenty drains of 250 ms each, one after another, would take five seconds. - let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect(); - let start = Instant::now(); - let (mut r, _w) = client(addr); - let refused = reply(&mut r).unwrap(); - assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}"); - assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed()); - drop(silent); - } - - /// A peer that keeps its connection open after its line was rejected gives its slot back at - /// the drain deadline (the write timeout), not at the idle timeout. - #[test] - fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() { - let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - let (mut r, mut w) = client(addr); - w.write_all(b"hello\n").unwrap(); - assert_eq!(reply(&mut r).unwrap()["ok"], false); - // `w` stays open, sending nothing. - let deadline = Instant::now() + Duration::from_secs(4); - loop { - let (mut dr, mut dw) = client(addr); - writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); - if reply(&mut dr).is_some_and(|v| v["ok"] == true) { - break; - } - assert!(Instant::now() < deadline, "slot never freed"); - std::thread::sleep(Duration::from_millis(50)); - } - drop(w); - } - - #[test] - fn oversized_line_closes_connection() { - let (addr, rx) = server(small()); - ui(rx); - let (mut r, mut w) = client(addr); - w.write_all(&[b'x'; 200]).unwrap(); - let v = reply(&mut r).unwrap(); - assert!(v["error"].as_str().unwrap().contains("longer than 64")); - assert!(reply(&mut r).is_none()); - } - - #[test] - fn idle_connection_is_closed() { - let (addr, rx) = server(small()); - ui(rx); - let (mut r, mut w) = client(addr); - w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence - let t = Instant::now(); - assert!(reply(&mut r).is_none()); - assert!(t.elapsed() < Duration::from_secs(4)); - } - - #[test] - fn connection_limit_refuses_then_frees_slot() { - // `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the - // third peer connected, and that peer then took its slot instead of reading `busy`. - let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - let a = client(addr); - let (mut br, mut bw) = client(addr); - // Make sure both are being served before the third arrives. - writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - assert!(reply(&mut br).is_some()); - let (mut cr, _cw) = client(addr); - let v = reply(&mut cr).unwrap(); - assert!(v["error"].as_str().unwrap().starts_with("busy")); - drop(a); - // The freed slot is reusable once the server notices the disconnect. - let deadline = Instant::now() + Duration::from_secs(4); - loop { - let (mut dr, mut dw) = client(addr); - writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); - if reply(&mut dr).is_some_and(|v| v["ok"] == true) { - break; - } - assert!(Instant::now() < deadline, "slot never freed"); - std::thread::sleep(Duration::from_millis(50)); - } - } - - #[test] - fn blocked_ui_times_out_and_full_queue_is_busy() { - let (addr, rx) = server(small()); - // No UI: the request that reaches the one-slot queue first fills it and times out; the - // other finds it full. Each connection has its own thread, so either request can be first. - let (mut ar, mut aw) = client(addr); - let (mut br, mut bw) = client(addr); - writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap(); - let a = reply(&mut ar).unwrap(); - let b = reply(&mut br).unwrap(); - assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2))); - let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()]; - errors.sort_unstable(); - assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}"); - drop(rx); - } - - /// A request that timed out while it was still queued must not run when the window resumes (#719). - #[test] - fn timed_out_request_does_not_run_when_the_window_resumes() { - let (addr, rx) = server(Limits { max_line: 512, ..small() }); - let (mut r, mut w) = client(addr); - writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap(); - let t = reply(&mut r).unwrap(); - assert_eq!(t["error"], "timeout"); - // The window resumes and drains the queue, exactly as `SlideApp::drain_control` does. - let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default()); - let ctx = egui::Context::default(); - let before = app.session.active().unwrap().doc.slides.len(); - let req = rx.try_recv().expect("the timed-out request is still queued"); - if req.take() { - let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req); - } - assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes"); - drop(rx); - } - - /// A request the app had already started when the reply timed out reports an unknown outcome, - /// not a definite failure (#719). - #[test] - fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() { - let (addr, rx) = server(small()); - let ui = std::thread::spawn(move || { - let req = rx.recv().expect("the request is queued"); - assert!(req.take(), "the app claims the request"); - // Still running it when the sender's wait expires. - std::thread::sleep(Duration::from_millis(450)); - let _ = req.reply.send(json!({"ok": true, "result": "ran"})); - }); - let (mut r, mut w) = client(addr); - writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap(); - let v = reply(&mut r).unwrap(); - assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}"); - ui.join().unwrap(); - } -} +mod tests; diff --git a/apps/deckcraft/src/control_server/tests.rs b/apps/deckcraft/src/control_server/tests.rs new file mode 100644 index 000000000..b83146557 --- /dev/null +++ b/apps/deckcraft/src/control_server/tests.rs @@ -0,0 +1,299 @@ +use super::*; + +/// A failed accept is followed by a wait before the next one, so a listener that keeps +/// failing does not spin, and the connections around the failures still come through. +#[test] +fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); +} +use std::net::SocketAddr; +use std::time::Instant; + +fn small() -> Limits { + Limits { + max_clients: 2, + max_line: 64, + queue: 1, + idle_timeout: Duration::from_millis(300), + write_timeout: Duration::from_millis(300), + reply_timeout: Duration::from_millis(300), + } +} + +fn server(limits: Limits) -> (SocketAddr, Receiver) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap(); + (addr, serve_listener(l, limits, egui::Context::default())) +} + +fn client(addr: SocketAddr) -> (BufReader, TcpStream) { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + (BufReader::new(s.try_clone().unwrap()), s) +} + +fn reply(r: &mut BufReader) -> Option { + let mut s = String::new(); + match r.read_line(&mut s) { + Ok(0) | Err(_) => None, + Ok(_) => serde_json::from_str(&s).ok(), + } +} + +/// Answer every request with its method name until the server goes away. +fn ui(rx: Receiver) { + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "result": req.method})); + } + }); +} + +#[test] +fn request_round_trip() { + let (addr, rx) = server(small()); + ui(rx); + let (mut r, mut w) = client(addr); + writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap(); + let v = reply(&mut r).unwrap(); + assert_eq!(v["id"], 7); + assert_eq!(v["result"], "ui.inspect"); +} + +/// Every reply line until the server closes the connection. +fn replies_until_closed(r: &mut BufReader) -> Vec { + std::iter::from_fn(|| reply(r)).collect() +} + +/// A browser can POST to the port: its request line closes the connection before the JSON +/// line in its body is read, so nothing is dispatched. +#[test] +fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { + let (addr, rx) = server(Limits { max_line: 512, ..small() }); + let (mut r, mut w) = client(addr); + w.write_all( + b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n", + ) + .unwrap(); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); + assert!(rx.try_recv().is_err(), "the body was dispatched"); +} + +#[test] +fn a_line_that_is_not_a_request_closes_the_connection() { + let (addr, rx) = server(small()); + ui(rx); + for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { + let (mut r, mut w) = client(addr); + w.write_all(junk).unwrap(); + w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); + } + assert!(is_http_request_line("OPTIONS * HTTP/1.1")); + assert!(is_http_request_line("PRI * HTTP/2.0")); + assert!(!is_http_request_line("{\"method\":\"GET /\"}")); +} + +/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its +/// line was rejected. Closing with its second line unread resets the connection and discards +/// the reply already on its way, so the peer saw nothing at all; repeated here because it +/// depended on the timing of the two. +#[test] +fn a_pipelined_peer_still_reads_why_its_line_was_rejected() { + // A slot comes back only when the thread serving a peer ends, which can be after the next + // peer connects, so every peer has a slot of its own. + const PEERS: usize = 12; + let (addr, rx) = server(Limits { max_clients: PEERS, ..small() }); + for _ in 0..PEERS { + let (mut r, mut w) = client(addr); + w.write_all(b"hello\n").unwrap(); + // A second line, written after the first as a script writes it: it races the close. + let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); + assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}"); + } + // The line after the rejected one is never dispatched, pipelined or not. + assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched"); +} + +/// A peer refused because every connection is taken still reads why when it had already +/// written a line: closing with that line unread resets the connection and discards the +/// reply. Repeated because it depends on the timing of the two. +#[test] +fn a_pipelined_peer_refused_as_busy_still_reads_why() { + let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + // Take the only slot, and make sure it is being served. + let (mut hr, mut hw) = client(addr); + writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + assert!(reply(&mut hr).is_some()); + for _ in 0..50 { + let (mut r, mut w) = client(addr); + let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}"); + } +} + +/// Peers refused as busy that stay connected without sending anything do not hold up the +/// connections after them: each one's drain runs to its deadline on a thread of its own, and +/// past `MAX_REFUSING` drains at once a refused connection is closed without one. +#[test] +fn silent_refused_peers_do_not_delay_the_next_refusal() { + let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + let (mut hr, mut hw) = client(addr); + writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + assert!(reply(&mut hr).is_some()); + // Twenty drains of 250 ms each, one after another, would take five seconds. + let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect(); + let start = Instant::now(); + let (mut r, _w) = client(addr); + let refused = reply(&mut r).unwrap(); + assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}"); + assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed()); + drop(silent); +} + +/// A peer that keeps its connection open after its line was rejected gives its slot back at +/// the drain deadline (the write timeout), not at the idle timeout. +#[test] +fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() { + let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + let (mut r, mut w) = client(addr); + w.write_all(b"hello\n").unwrap(); + assert_eq!(reply(&mut r).unwrap()["ok"], false); + // `w` stays open, sending nothing. + let deadline = Instant::now() + Duration::from_secs(4); + loop { + let (mut dr, mut dw) = client(addr); + writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); + if reply(&mut dr).is_some_and(|v| v["ok"] == true) { + break; + } + assert!(Instant::now() < deadline, "slot never freed"); + std::thread::sleep(Duration::from_millis(50)); + } + drop(w); +} + +#[test] +fn oversized_line_closes_connection() { + let (addr, rx) = server(small()); + ui(rx); + let (mut r, mut w) = client(addr); + w.write_all(&[b'x'; 200]).unwrap(); + let v = reply(&mut r).unwrap(); + assert!(v["error"].as_str().unwrap().contains("longer than 64")); + assert!(reply(&mut r).is_none()); +} + +#[test] +fn idle_connection_is_closed() { + let (addr, rx) = server(small()); + ui(rx); + let (mut r, mut w) = client(addr); + w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence + let t = Instant::now(); + assert!(reply(&mut r).is_none()); + assert!(t.elapsed() < Duration::from_secs(4)); +} + +#[test] +fn connection_limit_refuses_then_frees_slot() { + // `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the + // third peer connected, and that peer then took its slot instead of reading `busy`. + let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + let a = client(addr); + let (mut br, mut bw) = client(addr); + // Make sure both are being served before the third arrives. + writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + assert!(reply(&mut br).is_some()); + let (mut cr, _cw) = client(addr); + let v = reply(&mut cr).unwrap(); + assert!(v["error"].as_str().unwrap().starts_with("busy")); + drop(a); + // The freed slot is reusable once the server notices the disconnect. + let deadline = Instant::now() + Duration::from_secs(4); + loop { + let (mut dr, mut dw) = client(addr); + writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); + if reply(&mut dr).is_some_and(|v| v["ok"] == true) { + break; + } + assert!(Instant::now() < deadline, "slot never freed"); + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn blocked_ui_times_out_and_full_queue_is_busy() { + let (addr, rx) = server(small()); + // No UI: the request that reaches the one-slot queue first fills it and times out; the + // other finds it full. Each connection has its own thread, so either request can be first. + let (mut ar, mut aw) = client(addr); + let (mut br, mut bw) = client(addr); + writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap(); + let a = reply(&mut ar).unwrap(); + let b = reply(&mut br).unwrap(); + assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2))); + let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()]; + errors.sort_unstable(); + assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}"); + drop(rx); +} + +/// A request that timed out while it was still queued must not run when the window resumes (#719). +#[test] +fn timed_out_request_does_not_run_when_the_window_resumes() { + let (addr, rx) = server(Limits { max_line: 512, ..small() }); + let (mut r, mut w) = client(addr); + writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap(); + let t = reply(&mut r).unwrap(); + assert_eq!(t["error"], "timeout"); + // The window resumes and drains the queue, exactly as `SlideApp::drain_control` does. + let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default()); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.slides.len(); + let req = rx.try_recv().expect("the timed-out request is still queued"); + if req.take() { + let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req); + } + assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes"); + drop(rx); +} + +/// A request the app had already started when the reply timed out reports an unknown outcome, +/// not a definite failure (#719). +#[test] +fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() { + let (addr, rx) = server(small()); + let ui = std::thread::spawn(move || { + let req = rx.recv().expect("the request is queued"); + assert!(req.take(), "the app claims the request"); + // Still running it when the sender's wait expires. + std::thread::sleep(Duration::from_millis(450)); + let _ = req.reply.send(json!({"ok": true, "result": "ran"})); + }); + let (mut r, mut w) = client(addr); + writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap(); + let v = reply(&mut r).unwrap(); + assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}"); + ui.join().unwrap(); +} From 00eb5caad60afb7a6b05f7ebab350ba4212db875 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:35 +0000 Subject: [PATCH 17/90] refactor(fonts): move lib.rs tests to tests.rs --- crates/fonts/src/lib.rs | 330 +------------------------------------- crates/fonts/src/tests.rs | 326 +++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 329 deletions(-) create mode 100644 crates/fonts/src/tests.rs diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 883ceb4e0..92d2cb44d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -168,332 +168,4 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { } #[cfg(test)] -mod tests { - use super::*; - - fn any_face() -> std::sync::Arc { - FontDb::global().face(DEFAULT_FAMILY, "Regular") - } - - #[test] - fn emoji_clusters_are_recognised() { - let c = |s: &str| s.chars().collect::>(); - // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even - // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and - // keycaps always as text. - for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] - { - assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); - } - for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] - { - assert!(!is_emoji_cluster(&c(no)), "{no:?}"); - } - } - - #[test] - fn colour_fonts_are_recognised() { - let db = FontDb::with_font_dirs(Vec::new()); - assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); - assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); - assert!(!fontdb::last_resort_face().is_color()); - } - - #[test] - fn last_resort_face_parses() { - let f = fontdb::last_resort_face(); - assert_eq!(f.family, fontdb::LAST_RESORT_FAMILY); - assert!(f.upem > 0.0); - } - - #[test] - fn line_split_follows_the_windows_ascent_and_descent() { - use skrifa::raw::TableProvider; - let f = fontdb::last_resort_face(); - let font = skrifa::FontRef::new(f.data()).unwrap(); - let os2 = font.os2().unwrap(); - let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); - assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); - assert!(f.line_split > 0.5 && f.line_split < 1.0); - } - - #[test] - fn some_face_always_resolves() { - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - let f = db.face("No Such Font Anywhere", "Regular"); - assert!(!f.family.is_empty()); - assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); - } - - #[test] - fn filtered_fallback_continues_after_a_rejected_loaded_face() { - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); - let first = db.fallback_for('A', u32::MAX).unwrap(); - let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; - let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); - assert_eq!(fallback.family, accepted_family); - assert!(fallback.covers('A')); - assert_ne!(fallback.id(), first.id()); - assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); - assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let db = FontDb::with_font_dirs(vec![dir]); - let first = db.fallback_for('A', u32::MAX).unwrap(); - assert_ne!(first.family, "DeckCraft Legacy"); - assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); - assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); - let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); - assert_eq!(accepted.family, "DeckCraft Legacy"); - assert!(accepted.covers('A')); - } - - #[test] - fn concurrent_lookups_all_find_an_installed_family() { - // Threads resolving an installed family nobody had loaded yet used to fall back to - // another face when a different thread finished loading it first, so the same text was - // laid out in different fonts depending on timing. - let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); - for _ in 0..20 { - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - let start = std::sync::Barrier::new(8); - let got: Vec = std::thread::scope(|s| { - let threads: Vec<_> = (0..8) - .map(|_| { - s.spawn(|| { - start.wait(); - db.face("Hack", "Regular").family.clone() - }) - }) - .collect(); - threads.into_iter().map(|t| t.join().unwrap()).collect() - }); - assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn windows_family_names_find_their_faces() { - // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's - // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. - let check = |db: &FontDb| { - assert!(db.has_family("DeckCraft Legacy Semibold")); - for style in ["Regular", "Bold"] { - let f = db.face("DeckCraft Legacy Semibold", style); - assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); - } - assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); - // The typographic name still works. - assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); - }; - // Loaded directly… - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); - check(&db); - // …and found by the system font scan under its Windows name, then loaded. - #[cfg(not(target_arch = "wasm32"))] - { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - check(&db); - // Looking up the Windows name loads the Semibold file only; a later lookup of the - // family's Regular must still load and pick the Regular file, not settle for Semibold. - let db = FontDb::with_font_dirs(vec![dir]); - db.set_system_fallback(false); - assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); - let regular = db.face("DeckCraft Legacy", "Regular"); - assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); - } - } - - #[test] - fn faces_without_outlines_are_not_cataloged() { - // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in - // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag - // keeps the table directory sorted. - let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); - let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; - let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); - semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); - let loaded = FontDb::with_font_dirs(Vec::new()); - assert_eq!(loaded.add_font(semibold.clone()), 1); - let face = loaded.face("DeckCraft Legacy", "Semibold"); - assert!(face.covers('A')); - assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); - // The system font scan lists the Regular, which has outlines, and leaves out the Semibold - // under both of its names. - #[cfg(not(target_arch = "wasm32"))] - { - let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); - std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); - assert!(!db.has_family("DeckCraft Legacy Semibold")); - let _ = std::fs::remove_dir_all(&dir); - } - } - - #[test] - fn missing_document_fonts_are_drawn_in_calibri() { - // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. - let db = FontDb::global(); - for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { - let f = db.document_face(missing, "Bold"); - assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); - } - // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. - for (missing, twin) in - [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] - { - if !db.has_family(missing) { - assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); - } - } - // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. - // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. - for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { - assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); - } - } - - #[test] - fn office_fonts_have_substitutes() { - assert!(substitutes("Calibri").contains(&"Carlito")); - assert!(substitutes("Cambria").contains(&"Caladea")); - assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); - assert!(substitutes("Aptos").contains(&"Inter")); - assert!(substitutes("zzz").is_empty()); - } - - #[test] - fn shaping_produces_clusters_and_advances() { - let face = any_face(); - let g = shape(&face, "Hello", &[], |c| c); - assert_eq!(g.len(), 5); - assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); - assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); - } - - #[test] - fn mapping_keeps_clusters() { - let face = any_face(); - let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); - assert_eq!(g[0].gid, face.glyph_for('A')); - assert_eq!(g[1].cluster, 1); - } - - #[test] - fn outlines_and_metrics() { - let db = FontDb::global(); - let face = any_face(); - let o = db.outline(&face, face.glyph_for('O')); - assert!(!o.elements().is_empty()); - assert!(face.ascent > 0.0 && face.descent > 0.0); - assert!(feature("abc").is_none()); - assert!(feature("liga").is_some()); - } - - /// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; - /// required builds also assert their presence. - #[test] - fn craft_fonts_latin_when_present() { - let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); - if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { - assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); - eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); - return; - } - let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); - let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); - if required { - assert!( - variable.is_some() || (regular.is_some() && bold.is_some()), - "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" - ); - } - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.face("Inter", "Regular").family, "Inter"); - assert_eq!(db.face("Aptos", "Regular").family, "Inter"); - if let (Some(regular), Some(bold)) = (regular, bold) { - // The build input carries one entry per face; real static Bold must not be the Regular - // file relabelled in the manifest. - assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); - let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); - assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); - assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); - assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); - assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); - } - if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { - assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); - } - } - - /// An Embedded OpenType header of `version` around `font`, with the font at the end. - fn wrap_eot(font: &[u8], version: u32) -> Vec { - let mut h = vec![0u8; 82]; - let total = 82 + font.len(); - h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); - h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); - h[8..12].copy_from_slice(&version.to_le_bytes()); - h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); - h.extend_from_slice(font); - h - } - - #[test] - fn embedded_open_type_yields_the_font() { - let font = epaint_default_fonts::UBUNTU_LIGHT; - assert_eq!(embedded_sfnt(font), Some(font)); - assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); - assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); - assert!(embedded_sfnt(b"not a font file").is_none()); - assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); - let mut truncated = wrap_eot(font, 0x0002_0001); - truncated.truncate(40); - assert!(embedded_sfnt(&truncated).is_none()); - } - - /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them - /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and - /// still finds the real font beside them. - #[cfg(unix)] - #[test] - fn the_font_scan_skips_fifos_folders_and_link_loops() { - let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("sub")).unwrap(); - std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); - let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); - std::fs::create_dir(dir.join("folder.ttf")).unwrap(); - std::fs::write(dir.join("empty.otf"), b"").unwrap(); - std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - let scan_dir = dir.clone(); - std::thread::spawn(move || { - let db = FontDb::with_font_dirs(vec![scan_dir]); - let _ = tx.send(db.load_system_fonts()); - }); - let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); - assert!(n >= 1, "the real font was found"); - assert!(fifo || n >= 1); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs new file mode 100644 index 000000000..bb7b726ab --- /dev/null +++ b/crates/fonts/src/tests.rs @@ -0,0 +1,326 @@ +use super::*; + +fn any_face() -> std::sync::Arc { + FontDb::global().face(DEFAULT_FAMILY, "Regular") +} + +#[test] +fn emoji_clusters_are_recognised() { + let c = |s: &str| s.chars().collect::>(); + // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even + // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and + // keycaps always as text. + for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] + { + assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); + } + for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] { + assert!(!is_emoji_cluster(&c(no)), "{no:?}"); + } +} + +#[test] +fn colour_fonts_are_recognised() { + let db = FontDb::with_font_dirs(Vec::new()); + assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); + assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); + assert!(!fontdb::last_resort_face().is_color()); +} + +#[test] +fn last_resort_face_parses() { + let f = fontdb::last_resort_face(); + assert_eq!(f.family, fontdb::LAST_RESORT_FAMILY); + assert!(f.upem > 0.0); +} + +#[test] +fn line_split_follows_the_windows_ascent_and_descent() { + use skrifa::raw::TableProvider; + let f = fontdb::last_resort_face(); + let font = skrifa::FontRef::new(f.data()).unwrap(); + let os2 = font.os2().unwrap(); + let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); + assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); + assert!(f.line_split > 0.5 && f.line_split < 1.0); +} + +#[test] +fn some_face_always_resolves() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let f = db.face("No Such Font Anywhere", "Regular"); + assert!(!f.family.is_empty()); + assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); +} + +#[test] +fn filtered_fallback_continues_after_a_rejected_loaded_face() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); + let first = db.fallback_for('A', u32::MAX).unwrap(); + let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; + let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); + assert_eq!(fallback.family, accepted_family); + assert!(fallback.covers('A')); + assert_ne!(fallback.id(), first.id()); + assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); + assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir]); + let first = db.fallback_for('A', u32::MAX).unwrap(); + assert_ne!(first.family, "DeckCraft Legacy"); + assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); + assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); + let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); + assert_eq!(accepted.family, "DeckCraft Legacy"); + assert!(accepted.covers('A')); +} + +#[test] +fn concurrent_lookups_all_find_an_installed_family() { + // Threads resolving an installed family nobody had loaded yet used to fall back to + // another face when a different thread finished loading it first, so the same text was + // laid out in different fonts depending on timing. + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for _ in 0..20 { + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + let start = std::sync::Barrier::new(8); + let got: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + db.face("Hack", "Regular").family.clone() + }) + }) + .collect(); + threads.into_iter().map(|t| t.join().unwrap()).collect() + }); + assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn windows_family_names_find_their_faces() { + // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's + // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. + let check = |db: &FontDb| { + assert!(db.has_family("DeckCraft Legacy Semibold")); + for style in ["Regular", "Bold"] { + let f = db.face("DeckCraft Legacy Semibold", style); + assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); + } + assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); + // The typographic name still works. + assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); + }; + // Loaded directly… + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); + check(&db); + // …and found by the system font scan under its Windows name, then loaded. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + check(&db); + // Looking up the Windows name loads the Semibold file only; a later lookup of the + // family's Regular must still load and pick the Regular file, not settle for Semibold. + let db = FontDb::with_font_dirs(vec![dir]); + db.set_system_fallback(false); + assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); + let regular = db.face("DeckCraft Legacy", "Regular"); + assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); + } +} + +#[test] +fn faces_without_outlines_are_not_cataloged() { + // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in + // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag + // keeps the table directory sorted. + let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); + let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; + let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); + semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); + let loaded = FontDb::with_font_dirs(Vec::new()); + assert_eq!(loaded.add_font(semibold.clone()), 1); + let face = loaded.face("DeckCraft Legacy", "Semibold"); + assert!(face.covers('A')); + assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); + // The system font scan lists the Regular, which has outlines, and leaves out the Semibold + // under both of its names. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); + std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); + assert!(!db.has_family("DeckCraft Legacy Semibold")); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[test] +fn missing_document_fonts_are_drawn_in_calibri() { + // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. + let db = FontDb::global(); + for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { + let f = db.document_face(missing, "Bold"); + assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); + } + // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. + for (missing, twin) in + [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] + { + if !db.has_family(missing) { + assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); + } + } + // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. + // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. + for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { + assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); + } +} + +#[test] +fn office_fonts_have_substitutes() { + assert!(substitutes("Calibri").contains(&"Carlito")); + assert!(substitutes("Cambria").contains(&"Caladea")); + assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); + assert!(substitutes("Aptos").contains(&"Inter")); + assert!(substitutes("zzz").is_empty()); +} + +#[test] +fn shaping_produces_clusters_and_advances() { + let face = any_face(); + let g = shape(&face, "Hello", &[], |c| c); + assert_eq!(g.len(), 5); + assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); + assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); +} + +#[test] +fn mapping_keeps_clusters() { + let face = any_face(); + let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); + assert_eq!(g[0].gid, face.glyph_for('A')); + assert_eq!(g[1].cluster, 1); +} + +#[test] +fn outlines_and_metrics() { + let db = FontDb::global(); + let face = any_face(); + let o = db.outline(&face, face.glyph_for('O')); + assert!(!o.elements().is_empty()); + assert!(face.ascent > 0.0 && face.descent > 0.0); + assert!(feature("abc").is_none()); + assert!(feature("liga").is_some()); +} + +/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; +/// required builds also assert their presence. +#[test] +fn craft_fonts_latin_when_present() { + let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); + if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { + assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); + eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); + return; + } + let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); + let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); + if required { + assert!( + variable.is_some() || (regular.is_some() && bold.is_some()), + "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" + ); + } + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.face("Inter", "Regular").family, "Inter"); + assert_eq!(db.face("Aptos", "Regular").family, "Inter"); + if let (Some(regular), Some(bold)) = (regular, bold) { + // The build input carries one entry per face; real static Bold must not be the Regular + // file relabelled in the manifest. + assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); + let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); + assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); + assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); + assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); + assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); + } + if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { + assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); + } +} + +/// An Embedded OpenType header of `version` around `font`, with the font at the end. +fn wrap_eot(font: &[u8], version: u32) -> Vec { + let mut h = vec![0u8; 82]; + let total = 82 + font.len(); + h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); + h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); + h[8..12].copy_from_slice(&version.to_le_bytes()); + h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); + h.extend_from_slice(font); + h +} + +#[test] +fn embedded_open_type_yields_the_font() { + let font = epaint_default_fonts::UBUNTU_LIGHT; + assert_eq!(embedded_sfnt(font), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); + assert!(embedded_sfnt(b"not a font file").is_none()); + assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); + let mut truncated = wrap_eot(font, 0x0002_0001); + truncated.truncate(40); + assert!(embedded_sfnt(&truncated).is_none()); +} + +/// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them +/// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and +/// still finds the real font beside them. +#[cfg(unix)] +#[test] +fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); +} From 65a3ddc8f0d804ba2455dbf3675914c9ff255851 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:36 +0000 Subject: [PATCH 18/90] refactor(format): move lib.rs tests to tests.rs --- crates/format/src/lib.rs | 430 +------------------------------------ crates/format/src/tests.rs | 427 ++++++++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+), 429 deletions(-) create mode 100644 crates/format/src/tests.rs diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index cea34a6a6..7610398e6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -673,435 +673,7 @@ pub fn slides_to_outline(p: &Presentation) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn chart_sidecars_are_optional_for_older_native_files() { - use deckcraft_model::chart::{Chart, ChartType}; - let chart = Chart::sample(ChartType::Pie); - let mut json = serde_json::to_value(&chart).expect("chart JSON"); - assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); - assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); - json["kept"] = serde_json::json!({}); - assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); - } - - #[test] - fn native_save_keeps_chart_relationships_and_payloads() { - use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; - use deckcraft_model::{Shape, ShapeId, ShapeKind}; - let kept = ChartKept { - rels: vec![ChartRel { - id: "rWorkbook".into(), - rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), - target: "../embeddings/data.xlsx".into(), - part: "ppt/embeddings/data.xlsx".into(), - external: false, - }], - parts: vec![ChartPart { - name: "ppt/embeddings/data.xlsx".into(), - content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), - bytes: vec![0, 255, 128, 42], - native_entry: None, - }], - }; - let mut p = defaults::new_presentation(None); - let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; - let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; - // Serialization must reach charts inside groups as well as top-level charts. - Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { - id: ShapeId(p.alloc_id().expect("group id")), - kind: ShapeKind::Group { children: vec![s], child: Default::default() }, - ..Default::default() - }]; - let mut q = load(&save(&p).expect("save")).expect("load"); - for _ in 0..2 { - let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; - let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; - assert_eq!(chart.kept, kept); - q = load(&save(&q).expect("save again")).expect("load again"); - } - } - - /// The same package without the entries `drop` names. - fn without(bytes: &[u8], drop: &str) -> Vec { - let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - for i in 0..z.len() { - let f = z.by_index(i).unwrap(); - if f.name() != drop { - out.raw_copy_file(f).unwrap(); - } - } - out.finish().unwrap().into_inner() - } - - #[test] - fn missing_or_corrupt_media_is_reported() { - let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); - let good = save(&p).unwrap(); - assert!(load_checked(&good).unwrap().1.is_empty()); - // Missing member. - let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); - assert_eq!(damaged.len(), 1, "{damaged:?}"); - assert!(q.media(id).is_some_and(|m| m.data.is_empty())); - // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. - let mut bad = good.clone(); - let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); - bad[at] ^= 0xff; - let (_, damaged) = load_checked(&bad).unwrap(); - assert_eq!(damaged.len(), 1, "{damaged:?}"); - // Linked media saved without its bytes isn't damage. - let mut l = Presentation::default(); - let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); - if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { - m.link = Some("file:///movies/v.mp4".into()); - } - assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); - } - - #[test] - fn media_budget_is_cumulative() { - let mut p = Presentation::default(); - p.add_media("a.bin", "application/octet-stream", vec![1; 600]); - p.add_media("b.bin", "application/octet-stream", vec![2; 600]); - let bytes = save(&p).unwrap(); - // Exactly at the limit opens; one byte under is refused, though each item fits. - assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); - assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); - assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); - } - - #[test] - fn repeated_media_ids_are_read_once() { - use std::io::Write; - let mut p = Presentation::default(); - p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); - let good = save(&p).unwrap(); - // The same file with its media item listed 51 times. - let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - for i in 0..z.len() { - let mut f = z.by_index(i).unwrap(); - if f.name() == "presentation.json" { - let mut json = vec![]; - f.read_to_end(&mut json).unwrap(); - let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); - let media = &mut v["presentation"]["media"]; - let one = media[0].clone(); - *media = serde_json::Value::Array(vec![one; 51]); - out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); - } else { - out.raw_copy_file(f).unwrap(); - } - } - let bytes = out.finish().unwrap().into_inner(); - // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. - let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; - assert_eq!(q.media.len(), 1); - assert_eq!(q.media[0].data.len(), 1000); - // And the deck saves again. - assert!(save(&q).is_ok()); - } - - #[test] - fn roundtrip_with_media() { - let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); - let bytes = save(&p).unwrap(); - assert!(sniff(&bytes)); - let q = load(&bytes).unwrap(); - assert_eq!(q.slides.len(), p.slides.len()); - assert_eq!(q.media(id).map(|m| m.data.as_slice()), Some(&[1u8, 2, 3, 4][..])); - assert_eq!(serde_json::to_value(&q.slides).unwrap(), serde_json::to_value(&p.slides).unwrap()); - } - - #[test] - fn garbage_is_an_error_not_a_panic() { - assert!(load(b"").is_err()); - assert!(load(b"PK\x03\x04garbage").is_err()); - assert!(load(&[0u8; 1000]).is_err()); - let mut b = save(&Presentation::default()).unwrap(); - let n = b.len(); - b.truncate(n / 2); - assert!(load(&b).is_err()); - } - - /// A video's crop is brought into the same range as a picture's (#972). - #[test] - fn a_video_crop_is_repaired_like_a_picture_crop() { - use deckcraft_model::{MediaClip, Shape}; - let mut p = Presentation::default(); - let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; - assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); - } - - /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each - /// such number repaired (it failed as "invalid type: null, expected f64"). - #[test] - fn a_file_saved_with_non_finite_numbers_reopens() { - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = Presentation::default(); - let nan = f64::NAN; - let inf = f64::INFINITY; - let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; - pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; - pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); - let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; - let mut body = deckcraft_model::text::TextBody::default(); - let mut para = deckcraft_model::text::Paragraph::default(); - para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); - para.end_props.size = Some(1e9); - body.paragraphs.push(para); - text.text = Some(body); - let group = Shape { - xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), - kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, - ..Default::default() - }; - Arc::make_mut(&mut p.slides[0]).shapes.push(group); - let bytes = save(&p).unwrap(); - let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); - let g = back.slides[0].shapes.last().unwrap(); - let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; - assert_eq!((child.x, child.w), (0.0, 0.0)); - let x = children[0].xfrm.unwrap(); - assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); - let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; - assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); - assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); - assert_eq!(children[1].xfrm.unwrap().rot, 0.0); - let para = &children[1].text.as_ref().unwrap().paragraphs[0]; - assert_eq!(para.runs[0].props.size, None); - assert_eq!(para.end_props.size, Some(4000.0)); - // Saved again, it is clean. - assert_eq!(load(&save(&back).unwrap()).unwrap(), back); - } - - /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, - /// including a `null` inside a tagged enum, where serde reports only the enclosing object. - #[test] - fn older_files_with_null_numbers_open() { - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let nan = f64::NAN; - for case in 0..3 { - let mut p = Presentation::default(); - let sh = match case { - 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, - 1 => Shape { - kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, - ..Default::default() - }, - _ => Shape { - kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, - ..Default::default() - }, - }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; - let json = serde_json::to_vec(&m).unwrap(); - assert!(json.windows(4).any(|w| w == b"null")); - let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); - assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); - } - } - - /// A body whose file had no `fontScale` (``) keeps that state through the - /// native format: it is drawn at 100 % and only fitted when its text changes (#724). - #[test] - fn a_bare_normautofit_body_round_trips() { - use deckcraft_model::text::{AutoFit, TextBody}; - let mut p = Presentation::default(); - let mut body = TextBody::from_text("Overflowing"); - body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }); - let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); - assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); - // A scale the file saved round-trips as itself. - let mut body = TextBody::from_text("Overflowing"); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }); - let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); - assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 })); - } - - /// Only `null`s where numbers belong are patched: other damage still fails. - #[test] - fn other_damage_is_still_reported() { - let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); - assert!(parse_manifest(&ok).is_ok()); - let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; - assert!(parse_manifest(bad).is_err()); - let truncated = &ok[..ok.len() - 3]; - assert!(parse_manifest(truncated).is_err()); - // A null number on a later line (pretty-printed by another tool) is found too. - let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; - let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); - assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); - } - - #[test] - fn degenerate_slide_sizes_are_repaired() { - use deckcraft_model::Size; - let cases = [ - ((0.0, 0.0), (960.0, 540.0)), - ((-5.0, 540.0), (960.0, 540.0)), - ((f64::NAN, 540.0), (960.0, 540.0)), - ((f64::INFINITY, 1.0), (960.0, 540.0)), - ((1.0, 1.0), (72.0, 72.0)), - ((0.5, 4000.0), (72.0, 4000.0)), - ((1e9, 20.0), (4032.0, 72.0)), - ((720.0, 540.0), (720.0, 540.0)), - ]; - for ((w, h), want) in cases { - let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; - repair(&mut p); - assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); - assert_eq!(p.notes_size, Presentation::default().notes_size); - } - } - - /// A file whose slide size is null, a string or missing a side (written by hand or by - /// another tool; NaN is also saved as null) opens with the default size rather than failing - /// as damaged. - #[test] - fn files_with_unreadable_slide_sizes_still_open() { - let saved = save(&Presentation::default()).unwrap(); - let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); - let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); - for size in [ - serde_json::json!({"width": null, "height": 540}), - serde_json::json!({"width": "big", "height": 540}), - serde_json::json!({"height": 0}), - serde_json::json!(null), - serde_json::json!(7), - ] { - let mut m = json.clone(); - m["presentation"]["slideSize"] = size.clone(); - let mut buf = Cursor::new(Vec::new()); - { - let mut w = zip::ZipWriter::new(&mut buf); - let o = zip::write::SimpleFileOptions::default(); - w.start_file("mimetype", o).unwrap(); - w.write_all(MIMETYPE.as_bytes()).unwrap(); - w.start_file("presentation.json", o).unwrap(); - w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); - w.finish().unwrap(); - } - let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); - assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); - } - } - - #[test] - fn repair_fixes_missing_layout_and_size() { - let mut p = Presentation::default(); - Arc::make_mut(&mut p.slides[0]).layout = deckcraft_model::LayoutId(99999); - p.slide_size.width = f64::NAN; - repair(&mut p); - assert!(p.validate().is_empty()); - assert_eq!(p.slide_size.width, 960.0); - } - - /// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is - /// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). - #[test] - fn repair_keeps_a_duplicate_it_cannot_reid() { - let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; - p.slides.push(p.slides[0].clone()); - repair(&mut p); - assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); - assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); - } - - #[test] - fn outline_roundtrip() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - let n = outline_to_slides(&mut p, "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore"); - assert_eq!(n, 2); - let o = slides_to_outline(&p); - assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); - } - - #[test] - fn outline_keeps_leading_dash_text() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other"); - assert_eq!(n, 2); - let o = slides_to_outline(&p); - assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); - } - - #[test] - fn outline_export_keeps_multi_paragraph_title_on_one_line() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); - let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); - title.text = Some(TextBody::from_text("Line one\nLine two")); - let o = slides_to_outline(&p); - assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); - let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - assert_eq!(outline_to_slides(&mut q, &o), 2); - } - - #[test] - fn markdown_headings_and_list_items() { - assert_eq!(md_heading("# Intro"), Some("Intro")); - assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); - assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); - assert_eq!(md_heading("#"), Some("")); - assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); - for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { - assert_eq!(md_heading(not), None, "{not:?}"); - } - assert_eq!(md_list_item("- a"), Some((0, "a"))); - assert_eq!(md_list_item(" * b"), Some((2, "b"))); - assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); - assert_eq!(md_list_item("12) d"), Some((0, "d"))); - for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { - assert_eq!(md_list_item(not), None, "{not:?}"); - } - } - - /// #814: slides_to_outline includes table cell text, which it used to skip. - #[test] - fn outline_includes_table_text() { - use deckcraft_model::table::{Cell, Row, Table}; - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = defaults::new_presentation(None); - let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); - let mut s = defaults::new_slide(&mut p, layout).expect("slide"); - s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); - let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; - body.text = Some(TextBody::from_text("Text box body")); - s.shapes.push(body); - - let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; - for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { - let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); - table.rows.push(Row { height: 30.0, cells }); - } - let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; - tbl.kind = ShapeKind::Table(table); - s.shapes.push(tbl); - p.slides = vec![std::sync::Arc::new(s)]; - - let out = slides_to_outline(&p); - assert!(out.contains("Text box body"), "{out}"); - assert!(out.contains("Cell A1\tCell B1"), "{out}"); - assert!(out.contains("Cell A2\tCell B2"), "{out}"); - } -} +mod tests; #[test] fn outline_table_rows_are_indented_and_do_not_become_slides() { diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs new file mode 100644 index 000000000..dcd156367 --- /dev/null +++ b/crates/format/src/tests.rs @@ -0,0 +1,427 @@ +use super::*; + +#[test] +fn chart_sidecars_are_optional_for_older_native_files() { + use deckcraft_model::chart::{Chart, ChartType}; + let chart = Chart::sample(ChartType::Pie); + let mut json = serde_json::to_value(&chart).expect("chart JSON"); + assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); + assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); + json["kept"] = serde_json::json!({}); + assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); +} + +#[test] +fn native_save_keeps_chart_relationships_and_payloads() { + use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let kept = ChartKept { + rels: vec![ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/data.xlsx".into(), + part: "ppt/embeddings/data.xlsx".into(), + external: false, + }], + parts: vec![ChartPart { + name: "ppt/embeddings/data.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: vec![0, 255, 128, 42], + native_entry: None, + }], + }; + let mut p = defaults::new_presentation(None); + let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; + let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + // Serialization must reach charts inside groups as well as top-level charts. + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![s], child: Default::default() }, + ..Default::default() + }]; + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept, kept); + q = load(&save(&q).expect("save again")).expect("load again"); + } +} + +/// The same package without the entries `drop` names. +fn without(bytes: &[u8], drop: &str) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != drop { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() +} + +#[test] +fn missing_or_corrupt_media_is_reported() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); + let good = save(&p).unwrap(); + assert!(load_checked(&good).unwrap().1.is_empty()); + // Missing member. + let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + assert!(q.media(id).is_some_and(|m| m.data.is_empty())); + // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let (_, damaged) = load_checked(&bad).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + // Linked media saved without its bytes isn't damage. + let mut l = Presentation::default(); + let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); + if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { + m.link = Some("file:///movies/v.mp4".into()); + } + assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); +} + +#[test] +fn media_budget_is_cumulative() { + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![1; 600]); + p.add_media("b.bin", "application/octet-stream", vec![2; 600]); + let bytes = save(&p).unwrap(); + // Exactly at the limit opens; one byte under is refused, though each item fits. + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); +} + +#[test] +fn repeated_media_ids_are_read_once() { + use std::io::Write; + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); + let good = save(&p).unwrap(); + // The same file with its media item listed 51 times. + let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + let mut json = vec![]; + f.read_to_end(&mut json).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); + let media = &mut v["presentation"]["media"]; + let one = media[0].clone(); + *media = serde_json::Value::Array(vec![one; 51]); + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + let bytes = out.finish().unwrap().into_inner(); + // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. + let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; + assert_eq!(q.media.len(), 1); + assert_eq!(q.media[0].data.len(), 1000); + // And the deck saves again. + assert!(save(&q).is_ok()); +} + +#[test] +fn roundtrip_with_media() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); + let bytes = save(&p).unwrap(); + assert!(sniff(&bytes)); + let q = load(&bytes).unwrap(); + assert_eq!(q.slides.len(), p.slides.len()); + assert_eq!(q.media(id).map(|m| m.data.as_slice()), Some(&[1u8, 2, 3, 4][..])); + assert_eq!(serde_json::to_value(&q.slides).unwrap(), serde_json::to_value(&p.slides).unwrap()); +} + +#[test] +fn garbage_is_an_error_not_a_panic() { + assert!(load(b"").is_err()); + assert!(load(b"PK\x03\x04garbage").is_err()); + assert!(load(&[0u8; 1000]).is_err()); + let mut b = save(&Presentation::default()).unwrap(); + let n = b.len(); + b.truncate(n / 2); + assert!(load(&b).is_err()); +} + +/// A video's crop is brought into the same range as a picture's (#972). +#[test] +fn a_video_crop_is_repaired_like_a_picture_crop() { + use deckcraft_model::{MediaClip, Shape}; + let mut p = Presentation::default(); + let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; + assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); +} + +/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each +/// such number repaired (it failed as "invalid type: null, expected f64"). +#[test] +fn a_file_saved_with_non_finite_numbers_reopens() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = Presentation::default(); + let nan = f64::NAN; + let inf = f64::INFINITY; + let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; + pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); + let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; + let mut body = deckcraft_model::text::TextBody::default(); + let mut para = deckcraft_model::text::Paragraph::default(); + para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); + para.end_props.size = Some(1e9); + body.paragraphs.push(para); + text.text = Some(body); + let group = Shape { + xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), + kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(group); + let bytes = save(&p).unwrap(); + let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); + let g = back.slides[0].shapes.last().unwrap(); + let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; + assert_eq!((child.x, child.w), (0.0, 0.0)); + let x = children[0].xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; + assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); + assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); + assert_eq!(children[1].xfrm.unwrap().rot, 0.0); + let para = &children[1].text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.runs[0].props.size, None); + assert_eq!(para.end_props.size, Some(4000.0)); + // Saved again, it is clean. + assert_eq!(load(&save(&back).unwrap()).unwrap(), back); +} + +/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, +/// including a `null` inside a tagged enum, where serde reports only the enclosing object. +#[test] +fn older_files_with_null_numbers_open() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let nan = f64::NAN; + for case in 0..3 { + let mut p = Presentation::default(); + let sh = match case { + 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, + 1 => Shape { + kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, + ..Default::default() + }, + _ => Shape { + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }, + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; + let json = serde_json::to_vec(&m).unwrap(); + assert!(json.windows(4).any(|w| w == b"null")); + let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); + assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); + } +} + +/// A body whose file had no `fontScale` (``) keeps that state through the +/// native format: it is drawn at 100 % and only fitted when its text changes (#724). +#[test] +fn a_bare_normautofit_body_round_trips() { + use deckcraft_model::text::{AutoFit, TextBody}; + let mut p = Presentation::default(); + let mut body = TextBody::from_text("Overflowing"); + body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }); + let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); + assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); + // A scale the file saved round-trips as itself. + let mut body = TextBody::from_text("Overflowing"); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }); + let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); + assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 })); +} + +/// Only `null`s where numbers belong are patched: other damage still fails. +#[test] +fn other_damage_is_still_reported() { + let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); + assert!(parse_manifest(&ok).is_ok()); + let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; + assert!(parse_manifest(bad).is_err()); + let truncated = &ok[..ok.len() - 3]; + assert!(parse_manifest(truncated).is_err()); + // A null number on a later line (pretty-printed by another tool) is found too. + let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; + let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); +} + +#[test] +fn degenerate_slide_sizes_are_repaired() { + use deckcraft_model::Size; + let cases = [ + ((0.0, 0.0), (960.0, 540.0)), + ((-5.0, 540.0), (960.0, 540.0)), + ((f64::NAN, 540.0), (960.0, 540.0)), + ((f64::INFINITY, 1.0), (960.0, 540.0)), + ((1.0, 1.0), (72.0, 72.0)), + ((0.5, 4000.0), (72.0, 4000.0)), + ((1e9, 20.0), (4032.0, 72.0)), + ((720.0, 540.0), (720.0, 540.0)), + ]; + for ((w, h), want) in cases { + let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; + repair(&mut p); + assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); + assert_eq!(p.notes_size, Presentation::default().notes_size); + } +} + +/// A file whose slide size is null, a string or missing a side (written by hand or by +/// another tool; NaN is also saved as null) opens with the default size rather than failing +/// as damaged. +#[test] +fn files_with_unreadable_slide_sizes_still_open() { + let saved = save(&Presentation::default()).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + for size in [ + serde_json::json!({"width": null, "height": 540}), + serde_json::json!({"width": "big", "height": 540}), + serde_json::json!({"height": 0}), + serde_json::json!(null), + serde_json::json!(7), + ] { + let mut m = json.clone(); + m["presentation"]["slideSize"] = size.clone(); + let mut buf = Cursor::new(Vec::new()); + { + let mut w = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + w.start_file("mimetype", o).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + w.start_file("presentation.json", o).unwrap(); + w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); + w.finish().unwrap(); + } + let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); + } +} + +#[test] +fn repair_fixes_missing_layout_and_size() { + let mut p = Presentation::default(); + Arc::make_mut(&mut p.slides[0]).layout = deckcraft_model::LayoutId(99999); + p.slide_size.width = f64::NAN; + repair(&mut p); + assert!(p.validate().is_empty()); + assert_eq!(p.slide_size.width, 960.0); +} + +/// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is +/// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). +#[test] +fn repair_keeps_a_duplicate_it_cannot_reid() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + p.slides.push(p.slides[0].clone()); + repair(&mut p); + assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); + assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); +} + +#[test] +fn outline_roundtrip() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + let n = outline_to_slides(&mut p, "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore"); + assert_eq!(n, 2); + let o = slides_to_outline(&p); + assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); +} + +#[test] +fn outline_keeps_leading_dash_text() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other"); + assert_eq!(n, 2); + let o = slides_to_outline(&p); + assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); +} + +#[test] +fn outline_export_keeps_multi_paragraph_title_on_one_line() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); + let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); + title.text = Some(TextBody::from_text("Line one\nLine two")); + let o = slides_to_outline(&p); + assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); + let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut q, &o), 2); +} + +#[test] +fn markdown_headings_and_list_items() { + assert_eq!(md_heading("# Intro"), Some("Intro")); + assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); + assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); + assert_eq!(md_heading("#"), Some("")); + assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); + for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { + assert_eq!(md_heading(not), None, "{not:?}"); + } + assert_eq!(md_list_item("- a"), Some((0, "a"))); + assert_eq!(md_list_item(" * b"), Some((2, "b"))); + assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); + assert_eq!(md_list_item("12) d"), Some((0, "d"))); + for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { + assert_eq!(md_list_item(not), None, "{not:?}"); + } +} + +/// #814: slides_to_outline includes table cell text, which it used to skip. +#[test] +fn outline_includes_table_text() { + use deckcraft_model::table::{Cell, Row, Table}; + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; + body.text = Some(TextBody::from_text("Text box body")); + s.shapes.push(body); + + let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; + for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { + let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); + table.rows.push(Row { height: 30.0, cells }); + } + let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; + tbl.kind = ShapeKind::Table(table); + s.shapes.push(tbl); + p.slides = vec![std::sync::Arc::new(s)]; + + let out = slides_to_outline(&p); + assert!(out.contains("Text box body"), "{out}"); + assert!(out.contains("Cell A1\tCell B1"), "{out}"); + assert!(out.contains("Cell A2\tCell B2"), "{out}"); +} From b440c2ff40faaa0408b99e8edfb43058146142e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:37 +0000 Subject: [PATCH 19/90] refactor(geom): move preset tests to preset/tests.rs --- crates/geom/src/preset.rs | 423 +------------------------------- crates/geom/src/preset/tests.rs | 420 +++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 422 deletions(-) create mode 100644 crates/geom/src/preset/tests.rs diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 75e729d47..b6970e600 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -2227,425 +2227,4 @@ fn action_button(name: &str, w: f64, h: f64) -> Geometry { } #[cfg(test)] -mod tests { - use super::*; - use kurbo::Shape; - - #[test] - fn every_catalog_preset_builds_within_bounds() { - for p in CATALOG { - for (w, h) in [(200.0, 100.0), (100.0, 200.0), (50.0, 50.0), (0.0, 0.0), (1.0, 300.0)] { - let g = build(p.name, w, h, &[]).unwrap_or_else(|| panic!("{} did not build", p.name)); - assert!(!g.paths.is_empty(), "{} has no paths", p.name); - for s in &g.paths { - let bb = s.path.bounding_box(); - if w > 0.0 && h > 0.0 && !p.name.contains("Connector") && !p.name.contains("allout") { - let tol = w.max(h) * 0.35 + 1.0; - assert!(bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, "{} {w}x{h} out of bounds: {bb:?}", p.name); - } - assert!(bb.x0.is_finite() && bb.y1.is_finite(), "{} produced non-finite geometry", p.name); - } - assert!(g.text_rect.x0.is_finite() && g.text_rect.y1.is_finite(), "{}", p.name); - } - } - } - - #[test] - fn hostile_adjust_values_never_panic() { - for p in CATALOG { - for v in [f64::NAN, f64::INFINITY, -1e12, 1e12, -1.0, 0.0] { - let adj = vec![v; 6]; - let g = build(p.name, 120.0, 80.0, &adj); - assert!(g.is_some(), "{}", p.name); - } - } - assert!(build("rect", f64::NAN, 10.0, &[]).is_none()); - assert!(build("noSuchShape", 10.0, 10.0, &[]).is_none()); - } - - #[test] - fn catalog_names_are_unique() { - let mut names: Vec<&str> = CATALOG.iter().map(|p| p.name).collect(); - names.sort_unstable(); - let n = names.len(); - names.dedup(); - assert_eq!(n, names.len()); - assert!(n >= 140, "catalog has {n} presets"); - } - - #[test] - fn round_rect_handle_maps_back() { - let g = build("roundRect", 200.0, 100.0, &[16667.0]).unwrap(); - let h = g.handles.first().unwrap(); - assert!((h.value_at(h.pos) - 16667.0).abs() <= 1.0); - assert_eq!(h.value_at(Point::new(-50.0, 0.0)), 0.0); - assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0); - } - - #[test] - fn partial_circle_angle_handles_map_back() { - // Start 0° sits at the right of the ellipse, end 270° at its top. - for name in ["pie", "chord", "arc"] { - let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); - assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); - let (s, e) = (&g.handles[0], &g.handles[1]); - assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); - assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); - assert_eq!(s.value_at(s.pos), 0.0, "{name}"); - assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); - // Dragging to the bottom of the ellipse gives 90°. - assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); - // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 - // ellipse at offset (√2000, √2000) from the centre. - let k = 2000f64.sqrt(); - let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); - let (s, e) = (&g.handles[0], &g.handles[1]); - assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); - assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); - for (deg, h) in [(45.0, s), (135.0, e)] { - let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); - assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); - assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); - } - } - for deg in [45.0, 135.0, 200.0, 315.0] { - let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); - let start = g.handles.iter().find(|h| h.index == 0).unwrap(); - // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. - let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); - assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); - let d = start.pos - Point::new(100.0, 50.0); - assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); - assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); - } - let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); - let start = g.handles.iter().find(|h| h.index == 0).unwrap(); - assert_eq!(start.value_at(start.pos), 10800000.0); - let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); - assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); - } - - #[test] - fn presets_with_adjust_values_expose_handles() { - // Presets whose adjust values have no handle of their own yet. - const NO_HANDLES: &[&str] = &[ - "curvedConnector3", - "snip2SameRect", - "snip2DiagRect", - "snipRoundRect", - "round1Rect", - "round2SameRect", - "round2DiagRect", - "teardrop", - "halfFrame", - "corner", - "diagStripe", - "foldedCorner", - "smileyFace", - "moon", - "gear6", - "gear9", - "bracketPair", - "bracePair", - "leftBracket", - "rightBracket", - "leftBrace", - "rightBrace", - "leftRightArrow", - "upDownArrow", - "quadArrow", - "leftRightUpArrow", - "bentArrow", - "uturnArrow", - "curvedRightArrow", - "curvedLeftArrow", - "curvedUpArrow", - "curvedDownArrow", - "leftUpArrow", - "bentUpArrow", - "circularArrow", - "leftCircularArrow", - "leftRightCircularArrow", - "swooshArrow", - "rightArrowCallout", - "downArrowCallout", - "leftArrowCallout", - "upArrowCallout", - "leftRightArrowCallout", - "upDownArrowCallout", - "quadArrowCallout", - "mathPlus", - "mathMinus", - "mathMultiply", - "mathDivide", - "mathEqual", - "mathNotEqual", - "ribbon2", - "ribbon", - "ellipseRibbon2", - "ellipseRibbon", - "leftRightRibbon", - "verticalScroll", - "horizontalScroll", - "wave", - "doubleWave", - "borderCallout1", - "borderCallout2", - "borderCallout3", - "accentCallout1", - "accentCallout2", - "accentCallout3", - "callout1", - "callout2", - "callout3", - "accentBorderCallout1", - "accentBorderCallout2", - "accentBorderCallout3", - ]; - for p in CATALOG { - if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { - continue; - } - let g = build(p.name, 200.0, 100.0, &[]).unwrap(); - assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); - } - } - - #[test] - fn rect_area_and_ellipse_area() { - let g = build("rect", 100.0, 50.0, &[]).unwrap(); - assert!((g.paths[0].path.area().abs() - 5000.0).abs() < 1e-6); - let e = build("ellipse", 100.0, 50.0, &[]).unwrap(); - let expect = std::f64::consts::PI * 50.0 * 25.0; - assert!((e.paths[0].path.area().abs() - expect).abs() / expect < 0.01); - } - - #[test] - fn lines_are_open() { - assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); - assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); - } - - fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { - use kurbo::ParamCurveNearest; - g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) - } - - #[test] - fn fixed_shapes_follow_the_standards_coordinates() { - // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. - let (w, h) = (216.0, 108.0); - let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); - let g = build("lightningBolt", w, h, &[]).unwrap(); - for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { - assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); - } - let g = build("irregularSeal1", w, h, &[]).unwrap(); - assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); - let g = build("irregularSeal2", w, h, &[]).unwrap(); - assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); - // The heart's point is the middle of the bottom edge, its notch a quarter down. - let g = build("heart", w, h, &[]).unwrap(); - assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); - let g = build("flowChartDocument", w, h, &[]).unwrap(); - assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); - // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. - let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); - assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); - let g = build("flowChartMultidocument", w, h, &[]).unwrap(); - assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); - } - - #[test] - fn notched_arrow_notch_parallels_the_head() { - // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run - // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). - for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { - let g = build("notchedRightArrow", w, h, &adj).unwrap(); - let notch = g.paths[0].path.elements().iter().find_map(|e| match e { - kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), - _ => None, - }); - assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); - } - } - - #[test] - fn equation_shapes_match_powerpoints_outlines() { - // Corners read off PowerPoint's own outlines of the presets at their default adjusts. - let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); - for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { - assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); - } - let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); - for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { - assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); - } - let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); - assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); - // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches - // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). - let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); - use kurbo::Shape as _; - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); - } - - #[test] - fn sun_and_waves_match_powerpoints_outlines() { - // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). - let k = 216.0; - let g = build("sun", k, k, &[]).unwrap(); - for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); - } - let g = build("wave", k, k, &[]).unwrap(); - for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); - } - // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. - let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); - for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { - assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); - } - } - - #[test] - fn star_text_rectangles_follow_the_standard() { - // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the - // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. - let g = build("star5", 144.0, 144.0, &[]).unwrap(); - let t = g.text_rect; - assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); - assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); - // star4 at 12500: the square through its inner points' 45° corners. - let g = build("star4", 144.0, 144.0, &[]).unwrap(); - assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); - } - - #[test] - fn stars_are_stretched_like_the_standards() { - // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an - // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom - // points reach the box's bottom edge. - let k = 216.0; - let g = build("star5", k, k, &[]).unwrap(); - let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); - let a = 18f64.to_radians(); - assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); - assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); - use kurbo::Shape as _; - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); - // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). - let g = build("star6", k, k, &[]).unwrap(); - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); - } - - #[test] - fn moon_disk_and_drum_match_powerpoints_outlines() { - let k = 216.0; - // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. - let g = build("moon", k, k, &[]).unwrap(); - for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); - } - // 300 × 120 at 30%: its inner edge reaches 30% of the width in. - let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); - assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); - // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. - let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); - assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); - let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); - assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); - } - - #[test] - fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { - use kurbo::Shape as _; - for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { - let g = build("cloud", w, h, &[]).unwrap(); - let bb = g.paths[0].path.bounding_box(); - assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); - assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); - assert_eq!(g.paths.len(), 2, "outline and creases"); - assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); - } - } - - #[test] - fn spec_arcs_take_angles_as_directions_from_the_centre() { - // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the - // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. - let mut b = Pb::new(); - b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); - let p = b.take(); - let end = p.elements().last().and_then(|e| match e { - kurbo::PathEl::CurveTo(_, _, q) => Some(*q), - _ => None, - }); - // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. - let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); - let c = Point::new(-k, -k); - let q = end.unwrap(); - assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); - } - - #[test] - fn brackets_and_braces_fill_out_to_their_straight_side() { - // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the - // straight side it opens to; only the curve is outlined. - for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { - let g = build(name, 40.0, 100.0, &[]).unwrap(); - let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); - assert_eq!(filled.len(), 1, "{name}"); - assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); - let area = filled[0].path.area().abs(); - assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); - assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); - } - } - - /// The end points of a path's move/line elements, in order. - fn vertices(p: &BezPath) -> Vec { - p.elements() - .iter() - .filter_map(|e| match e { - kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), - _ => None, - }) - .collect() - } - - #[test] - fn wedge_callout_mirrors_with_its_tip() { - // A tip below and right of the centre draws the mirror image of the same tip on the left; - // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts - // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at - // 7..10 twelfths on the far side. - let (w, h) = (240.0, 120.0); - // Sorted and deduplicated: the outline closes on the corner it starts from, which the - // mirror moves to the opposite corner. - let sorted = |mut v: Vec| { - v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); - v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); - v - }; - for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { - let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); - let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); - let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); - let (a, b) = (sorted(a), sorted(b)); - assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); - for (p, q) in a.iter().zip(&b) { - assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); - } - } - // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. - let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); - for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { - assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); - } - } -} +mod tests; diff --git a/crates/geom/src/preset/tests.rs b/crates/geom/src/preset/tests.rs new file mode 100644 index 000000000..dc18dcdb5 --- /dev/null +++ b/crates/geom/src/preset/tests.rs @@ -0,0 +1,420 @@ +use super::*; +use kurbo::Shape; + +#[test] +fn every_catalog_preset_builds_within_bounds() { + for p in CATALOG { + for (w, h) in [(200.0, 100.0), (100.0, 200.0), (50.0, 50.0), (0.0, 0.0), (1.0, 300.0)] { + let g = build(p.name, w, h, &[]).unwrap_or_else(|| panic!("{} did not build", p.name)); + assert!(!g.paths.is_empty(), "{} has no paths", p.name); + for s in &g.paths { + let bb = s.path.bounding_box(); + if w > 0.0 && h > 0.0 && !p.name.contains("Connector") && !p.name.contains("allout") { + let tol = w.max(h) * 0.35 + 1.0; + assert!(bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, "{} {w}x{h} out of bounds: {bb:?}", p.name); + } + assert!(bb.x0.is_finite() && bb.y1.is_finite(), "{} produced non-finite geometry", p.name); + } + assert!(g.text_rect.x0.is_finite() && g.text_rect.y1.is_finite(), "{}", p.name); + } + } +} + +#[test] +fn hostile_adjust_values_never_panic() { + for p in CATALOG { + for v in [f64::NAN, f64::INFINITY, -1e12, 1e12, -1.0, 0.0] { + let adj = vec![v; 6]; + let g = build(p.name, 120.0, 80.0, &adj); + assert!(g.is_some(), "{}", p.name); + } + } + assert!(build("rect", f64::NAN, 10.0, &[]).is_none()); + assert!(build("noSuchShape", 10.0, 10.0, &[]).is_none()); +} + +#[test] +fn catalog_names_are_unique() { + let mut names: Vec<&str> = CATALOG.iter().map(|p| p.name).collect(); + names.sort_unstable(); + let n = names.len(); + names.dedup(); + assert_eq!(n, names.len()); + assert!(n >= 140, "catalog has {n} presets"); +} + +#[test] +fn round_rect_handle_maps_back() { + let g = build("roundRect", 200.0, 100.0, &[16667.0]).unwrap(); + let h = g.handles.first().unwrap(); + assert!((h.value_at(h.pos) - 16667.0).abs() <= 1.0); + assert_eq!(h.value_at(Point::new(-50.0, 0.0)), 0.0); + assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0); +} + +#[test] +fn partial_circle_angle_handles_map_back() { + // Start 0° sits at the right of the ellipse, end 270° at its top. + for name in ["pie", "chord", "arc"] { + let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); + assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); + assert_eq!(s.value_at(s.pos), 0.0, "{name}"); + assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); + // Dragging to the bottom of the ellipse gives 90°. + assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); + // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 + // ellipse at offset (√2000, √2000) from the centre. + let k = 2000f64.sqrt(); + let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); + for (deg, h) in [(45.0, s), (135.0, e)] { + let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); + assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); + assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); + } + } + for deg in [45.0, 135.0, 200.0, 315.0] { + let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. + let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); + assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); + let d = start.pos - Point::new(100.0, 50.0); + assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); + assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); + } + let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + assert_eq!(start.value_at(start.pos), 10800000.0); + let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); + assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); +} + +#[test] +fn presets_with_adjust_values_expose_handles() { + // Presets whose adjust values have no handle of their own yet. + const NO_HANDLES: &[&str] = &[ + "curvedConnector3", + "snip2SameRect", + "snip2DiagRect", + "snipRoundRect", + "round1Rect", + "round2SameRect", + "round2DiagRect", + "teardrop", + "halfFrame", + "corner", + "diagStripe", + "foldedCorner", + "smileyFace", + "moon", + "gear6", + "gear9", + "bracketPair", + "bracePair", + "leftBracket", + "rightBracket", + "leftBrace", + "rightBrace", + "leftRightArrow", + "upDownArrow", + "quadArrow", + "leftRightUpArrow", + "bentArrow", + "uturnArrow", + "curvedRightArrow", + "curvedLeftArrow", + "curvedUpArrow", + "curvedDownArrow", + "leftUpArrow", + "bentUpArrow", + "circularArrow", + "leftCircularArrow", + "leftRightCircularArrow", + "swooshArrow", + "rightArrowCallout", + "downArrowCallout", + "leftArrowCallout", + "upArrowCallout", + "leftRightArrowCallout", + "upDownArrowCallout", + "quadArrowCallout", + "mathPlus", + "mathMinus", + "mathMultiply", + "mathDivide", + "mathEqual", + "mathNotEqual", + "ribbon2", + "ribbon", + "ellipseRibbon2", + "ellipseRibbon", + "leftRightRibbon", + "verticalScroll", + "horizontalScroll", + "wave", + "doubleWave", + "borderCallout1", + "borderCallout2", + "borderCallout3", + "accentCallout1", + "accentCallout2", + "accentCallout3", + "callout1", + "callout2", + "callout3", + "accentBorderCallout1", + "accentBorderCallout2", + "accentBorderCallout3", + ]; + for p in CATALOG { + if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { + continue; + } + let g = build(p.name, 200.0, 100.0, &[]).unwrap(); + assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); + } +} + +#[test] +fn rect_area_and_ellipse_area() { + let g = build("rect", 100.0, 50.0, &[]).unwrap(); + assert!((g.paths[0].path.area().abs() - 5000.0).abs() < 1e-6); + let e = build("ellipse", 100.0, 50.0, &[]).unwrap(); + let expect = std::f64::consts::PI * 50.0 * 25.0; + assert!((e.paths[0].path.area().abs() - expect).abs() / expect < 0.01); +} + +#[test] +fn lines_are_open() { + assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); + assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); +} + +fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { + use kurbo::ParamCurveNearest; + g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) +} + +#[test] +fn fixed_shapes_follow_the_standards_coordinates() { + // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. + let (w, h) = (216.0, 108.0); + let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); + let g = build("lightningBolt", w, h, &[]).unwrap(); + for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { + assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); + } + let g = build("irregularSeal1", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); + let g = build("irregularSeal2", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); + // The heart's point is the middle of the bottom edge, its notch a quarter down. + let g = build("heart", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); + let g = build("flowChartDocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); + // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. + let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); + let g = build("flowChartMultidocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); +} + +#[test] +fn notched_arrow_notch_parallels_the_head() { + // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run + // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). + for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { + let g = build("notchedRightArrow", w, h, &adj).unwrap(); + let notch = g.paths[0].path.elements().iter().find_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), + _ => None, + }); + assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); + } +} + +#[test] +fn equation_shapes_match_powerpoints_outlines() { + // Corners read off PowerPoint's own outlines of the presets at their default adjusts. + let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); + for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { + assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); + } + let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); + for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); + } + let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); + assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); + // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches + // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). + let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); +} + +#[test] +fn sun_and_waves_match_powerpoints_outlines() { + // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). + let k = 216.0; + let g = build("sun", k, k, &[]).unwrap(); + for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); + } + let g = build("wave", k, k, &[]).unwrap(); + for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); + } + // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. + let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); + for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); + } +} + +#[test] +fn star_text_rectangles_follow_the_standard() { + // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the + // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. + let g = build("star5", 144.0, 144.0, &[]).unwrap(); + let t = g.text_rect; + assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); + assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); + // star4 at 12500: the square through its inner points' 45° corners. + let g = build("star4", 144.0, 144.0, &[]).unwrap(); + assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); +} + +#[test] +fn stars_are_stretched_like_the_standards() { + // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an + // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom + // points reach the box's bottom edge. + let k = 216.0; + let g = build("star5", k, k, &[]).unwrap(); + let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); + let a = 18f64.to_radians(); + assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); + assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); + // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). + let g = build("star6", k, k, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); +} + +#[test] +fn moon_disk_and_drum_match_powerpoints_outlines() { + let k = 216.0; + // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. + let g = build("moon", k, k, &[]).unwrap(); + for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); + } + // 300 × 120 at 30%: its inner edge reaches 30% of the width in. + let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); + assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); + // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. + let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); + let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); +} + +#[test] +fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { + use kurbo::Shape as _; + for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { + let g = build("cloud", w, h, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); + assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); + assert_eq!(g.paths.len(), 2, "outline and creases"); + assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); + } +} + +#[test] +fn spec_arcs_take_angles_as_directions_from_the_centre() { + // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the + // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. + let mut b = Pb::new(); + b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); + let p = b.take(); + let end = p.elements().last().and_then(|e| match e { + kurbo::PathEl::CurveTo(_, _, q) => Some(*q), + _ => None, + }); + // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. + let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); + let c = Point::new(-k, -k); + let q = end.unwrap(); + assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); +} + +#[test] +fn brackets_and_braces_fill_out_to_their_straight_side() { + // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the + // straight side it opens to; only the curve is outlined. + for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { + let g = build(name, 40.0, 100.0, &[]).unwrap(); + let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); + assert_eq!(filled.len(), 1, "{name}"); + assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); + let area = filled[0].path.area().abs(); + assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); + assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); + } +} + +/// The end points of a path's move/line elements, in order. +fn vertices(p: &BezPath) -> Vec { + p.elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), + _ => None, + }) + .collect() +} + +#[test] +fn wedge_callout_mirrors_with_its_tip() { + // A tip below and right of the centre draws the mirror image of the same tip on the left; + // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts + // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at + // 7..10 twelfths on the far side. + let (w, h) = (240.0, 120.0); + // Sorted and deduplicated: the outline closes on the corner it starts from, which the + // mirror moves to the opposite corner. + let sorted = |mut v: Vec| { + v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); + v + }; + for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { + let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); + let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); + let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); + let (a, b) = (sorted(a), sorted(b)); + assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); + for (p, q) in a.iter().zip(&b) { + assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); + } + } + // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. + let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); + for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { + assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); + } +} From a2194407a1a264dd054751c2adee344f9841b091 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:37 +0000 Subject: [PATCH 20/90] refactor(media): move player tests to player/tests.rs --- crates/media/src/player.rs | 309 +------------------------------ crates/media/src/player/tests.rs | 306 ++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 308 deletions(-) create mode 100644 crates/media/src/player/tests.rs diff --git a/crates/media/src/player.rs b/crates/media/src/player.rs index 3c972f2c7..6770b191c 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -515,311 +515,4 @@ impl Drop for Player { } #[cfg(test)] -mod tests { - use super::*; - - fn spec(duration: f64, looping: bool) -> VoiceSpec { - VoiceSpec { clip: ClipParams::default(), looping, duration, audio: false } - } - - #[test] - fn wall_clock_advances_pauses_and_seeks() { - let mut p = Player::new(None); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(2.0, false), None); - p.tick(10.0); - p.tick(10.2); - p.tick(10.4); - let s = p.status(1).map(|s| s.position).unwrap_or(-1.0); - assert!((s - 0.4).abs() < 1e-3, "{s}"); - p.pause(1); - p.tick(10.6); - assert!((p.status(1).map(|s| s.position).unwrap_or(-1.0) - 0.4).abs() < 1e-3); - assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Paused)); - p.seek(1, 1.5); - p.play(1, 1, &b, spec(2.0, false), None); - p.tick(10.8); - p.tick(11.0); - p.tick(11.2); - p.tick(11.4); - let st = p.status(1); - assert_eq!(st.as_ref().map(|s| s.state), Some(PlayState::Ended)); - assert_eq!(st.map(|s| s.position), Some(2.0)); - // Playing an ended voice starts over. - p.play(1, 1, &b, spec(2.0, false), None); - assert_eq!(p.status(1).map(|s| s.position), Some(0.0)); - } - - #[test] - fn looping_wraps_within_the_trimmed_range() { - let mut p = Player::new(None); - let b: Bytes = Arc::new(vec![]); - let mut s = spec(1.0, true); - s.clip.trim_start = 0.5; - p.play(7, 7, &b, s, None); - p.tick(0.0); - for i in 1..=4 { - p.tick(i as f64 * 0.2); - } - // 0.8 s played in a 0.5 s loop starting at 0.5: 0.5 + (0.8 mod 0.5) = 0.8. - let pos = p.status(7).map(|s| s.position).unwrap_or(-1.0); - assert!((pos - 0.8).abs() < 1e-3, "{pos}"); - assert_eq!(p.status(7).map(|s| s.state), Some(PlayState::Playing)); - } - - #[test] - fn mixer_applies_volume_fade_and_channel_mapping() { - let pcm = Arc::new(Pcm { rate: 10, channels: 1, samples: vec![1.0; 10] }); - let slot: Slot = Arc::new(OnceLock::new()); - let _ = slot.set(Ok(pcm)); - let clip = ClipParams { fade_in: 0.5, volume: 0.5, ..Default::default() }; - let mut m = Mixer { - voices: vec![Voice { - key: 1, - media_key: 1, - audio: Some(slot), - spec: VoiceSpec { clip, looping: false, duration: 1.0, audio: true }, - t: 0.0, - state: PlayState::Playing, - }], - }; - let mut buf = vec![0.0f32; 2 * 12]; - m.mix(Some(&mut buf), 12, 2, 10); - // Frame 0: fade starts at 0; frame 3: 0.3/0.5 × 0.5 = 0.3; both channels carry the mono source. - assert_eq!(buf[0], 0.0); - assert!((buf[6] - 0.3).abs() < 1e-5 && (buf[7] - 0.3).abs() < 1e-5, "{buf:?}"); - assert!((buf[16] - 0.5).abs() < 1e-5); - // Past the end: silence, and the voice has ended. - assert_eq!(buf[22], 0.0); - assert_eq!(m.voices[0].state, PlayState::Ended); - } - - #[test] - fn device_clock_drives_position() { - struct Fake(Arc>>>); - impl AudioOut for Fake { - fn start(&mut self, fill: Box) -> Result<(), String> { - *self.0.lock().map_err(|e| e.to_string())? = Some(fill); - Ok(()) - } - fn stop(&mut self) {} - } - let cb = Arc::new(Mutex::new(None)); - let mut p = Player::new(Some(Box::new(Fake(cb.clone())))); - let b: Bytes = Arc::new(vec![]); - p.play(3, 3, &b, spec(10.0, false), None); - // Wall time alone doesn't move a device-clocked voice… - p.tick(0.0); - p.tick(0.2); - assert_eq!(p.status(3).map(|s| s.position), Some(0.0)); - // …the device pulling 4800 frames at 48 kHz does. - let mut buf = vec![0.0; 4800 * 2]; - if let Some(f) = cb.lock().ok().and_then(|mut g| g.take()).as_mut() { - f(&mut buf, 2, 48_000); - } - let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); - assert!((pos - 0.1).abs() < 1e-6, "{pos}"); - } - - /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. - fn wav_level(secs: f64, amp: f32) -> Bytes { - let rate = 8_000u32; - let n = (rate as f64 * secs).round() as usize; - let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); - let mut b = Vec::new(); - b.extend_from_slice(b"RIFF"); - b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); - b.extend_from_slice(b"WAVEfmt "); - b.extend_from_slice(&16u32.to_le_bytes()); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&rate.to_le_bytes()); - b.extend_from_slice(&(rate * 2).to_le_bytes()); - b.extend_from_slice(&2u16.to_le_bytes()); - b.extend_from_slice(&16u16.to_le_bytes()); - b.extend_from_slice(b"data"); - b.extend_from_slice(&(data.len() as u32).to_le_bytes()); - b.extend_from_slice(&data); - Arc::new(b) - } - - fn audible(duration: f64) -> VoiceSpec { - VoiceSpec { clip: ClipParams::default(), looping: false, duration, audio: true } - } - - /// First mixed sample of voice `key`, after its audio has decoded. - fn first_sample(p: &Player, key: u64) -> f32 { - assert!(p.wait_loaded(key, std::time::Duration::from_secs(5)), "decode"); - let mut buf = vec![0.0f32; 4]; - p.render(&mut buf, 1, 8_000); - buf[0] - } - - #[test] - fn replacing_the_source_plays_the_new_audio_and_keeps_its_bytes() { - let mut p = Player::new(None); - let loud = wav_level(0.05, 0.5); - let quiet = wav_level(0.10, 0.25); - let loud_key = Arc::as_ptr(&loud) as u64; - let quiet_key = Arc::as_ptr(&quiet) as u64; - assert_ne!(loud_key, quiet_key); - - // Duration 0: the clock takes its length from the decoded audio, so a stale slot would - // report the previous clip. - p.play(1, loud_key, &loud, audible(0.0), None); - let got = first_sample(&p, 1); - assert!((got - 0.5).abs() < 0.02, "loud sample {got}"); - let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); - assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); - assert!(p.cache.get(&loud_key).is_some_and(|c| Arc::ptr_eq(&c.source, &loud))); - - // The caller's buffer can go away; the cache still owns the allocation its key names. - drop(loud); - assert_eq!(Arc::as_ptr(&p.cache.get(&loud_key).expect("cached").source) as u64, loud_key); - - p.play(1, quiet_key, &quiet, audible(0.0), None); - let got = first_sample(&p, 1); - assert!((got - 0.25).abs() < 0.02, "quiet sample {got}"); - let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); - assert!((dur - 0.10).abs() < 0.01, "quiet duration {dur}"); - assert!(p.cache.get(&quiet_key).is_some_and(|c| Arc::ptr_eq(&c.source, &quiet))); - // Resuming the same source does not jump back to the start. - let pos = p.status(1).map(|s| s.position).unwrap_or(-1.0); - p.play(1, quiet_key, &quiet, audible(0.0), None); - let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); - assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); - } - - type Fill = Box; - - /// An output that starts, then fails or goes quiet on command. - #[derive(Clone, Default)] - struct Flaky { - fill: Arc>>, - fail: Arc>>, - starts: Arc>, - } - impl AudioOut for Flaky { - fn start(&mut self, fill: Fill) -> Result<(), String> { - *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); - *self.starts.lock().map_err(|e| e.to_string())? += 1; - Ok(()) - } - fn stop(&mut self) { - if let Ok(mut f) = self.fill.lock() { - *f = None; - } - } - fn failure(&mut self) -> Option { - self.fail.lock().ok().and_then(|mut f| f.take()) - } - } - impl Flaky { - fn pull(&self, frames: usize) { - let mut buf = vec![0.0; frames * 2]; - if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { - f(&mut buf, 2, 48_000); - } - } - fn starts(&self) -> u32 { - self.starts.lock().map(|s| *s).unwrap_or(0) - } - } - - fn pos(p: &Player, key: u64) -> f64 { - p.status(key).map(|s| s.position).unwrap_or(-1.0) - } - - #[test] - fn a_failed_output_falls_back_to_the_wall_clock() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - p.tick(0.0); - out.pull(4800); - assert!((pos(&p, 1) - 0.1).abs() < 1e-6); - if let Ok(mut f) = out.fail.lock() { - *f = Some("unplugged".into()); - } - p.tick(0.2); - p.tick(0.4); - // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then - // the 0.4 s of wall time since the last tick before the failure. - assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); - assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); - // Playing again restarts the device. - p.play(2, 2, &b, spec(10.0, false), None); - assert_eq!(out.starts(), 2); - assert!(p.has_output()); - } - - #[test] - fn a_stalled_output_falls_back_to_the_wall_clock() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - // No callback ever arrives: after the first-callback grace the wall clock takes over. - for i in 0..=21 { - p.tick(i as f64 * 0.25); - } - let before = pos(&p, 1); - assert!(before > 0.0, "{before}"); - p.tick(5.5); - assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); - } - - #[test] - fn a_slow_device_that_answers_within_the_grace_is_not_a_stall() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - for i in 0..=8 { - p.tick(i as f64 * 0.25); - } - // Two seconds in, the device starts asking for samples: the same stream keeps playing. - out.pull(256); - p.tick(2.25); - p.tick(2.5); - assert_eq!(out.starts(), 1, "the stream was not restarted"); - } - - #[test] - fn an_idle_stream_without_callbacks_is_not_a_stall() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - p.pause(1); - for i in 0..=8 { - p.tick(i as f64 * 0.25); - } - // Resuming keeps the running stream: no restart. - p.play(1, 1, &b, spec(10.0, false), None); - assert_eq!(out.starts(), 1); - } - - #[test] - fn repeated_failures_give_up_on_the_device() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - let mut now = 0.0; - for _ in 0..MAX_OUTPUT_FAILURES { - p.play(1, 1, &b, spec(100.0, false), None); - if let Ok(mut f) = out.fail.lock() { - *f = Some("broken".into()); - } - p.tick(now); - now += 0.1; - } - assert!(!p.has_output()); - assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); - // Still plays, silently on wall time. - let before = pos(&p, 1); - p.tick(now + 0.2); - assert!(pos(&p, 1) > before); - } -} +mod tests; diff --git a/crates/media/src/player/tests.rs b/crates/media/src/player/tests.rs new file mode 100644 index 000000000..0e2f85b0b --- /dev/null +++ b/crates/media/src/player/tests.rs @@ -0,0 +1,306 @@ +use super::*; + +fn spec(duration: f64, looping: bool) -> VoiceSpec { + VoiceSpec { clip: ClipParams::default(), looping, duration, audio: false } +} + +#[test] +fn wall_clock_advances_pauses_and_seeks() { + let mut p = Player::new(None); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(2.0, false), None); + p.tick(10.0); + p.tick(10.2); + p.tick(10.4); + let s = p.status(1).map(|s| s.position).unwrap_or(-1.0); + assert!((s - 0.4).abs() < 1e-3, "{s}"); + p.pause(1); + p.tick(10.6); + assert!((p.status(1).map(|s| s.position).unwrap_or(-1.0) - 0.4).abs() < 1e-3); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Paused)); + p.seek(1, 1.5); + p.play(1, 1, &b, spec(2.0, false), None); + p.tick(10.8); + p.tick(11.0); + p.tick(11.2); + p.tick(11.4); + let st = p.status(1); + assert_eq!(st.as_ref().map(|s| s.state), Some(PlayState::Ended)); + assert_eq!(st.map(|s| s.position), Some(2.0)); + // Playing an ended voice starts over. + p.play(1, 1, &b, spec(2.0, false), None); + assert_eq!(p.status(1).map(|s| s.position), Some(0.0)); +} + +#[test] +fn looping_wraps_within_the_trimmed_range() { + let mut p = Player::new(None); + let b: Bytes = Arc::new(vec![]); + let mut s = spec(1.0, true); + s.clip.trim_start = 0.5; + p.play(7, 7, &b, s, None); + p.tick(0.0); + for i in 1..=4 { + p.tick(i as f64 * 0.2); + } + // 0.8 s played in a 0.5 s loop starting at 0.5: 0.5 + (0.8 mod 0.5) = 0.8. + let pos = p.status(7).map(|s| s.position).unwrap_or(-1.0); + assert!((pos - 0.8).abs() < 1e-3, "{pos}"); + assert_eq!(p.status(7).map(|s| s.state), Some(PlayState::Playing)); +} + +#[test] +fn mixer_applies_volume_fade_and_channel_mapping() { + let pcm = Arc::new(Pcm { rate: 10, channels: 1, samples: vec![1.0; 10] }); + let slot: Slot = Arc::new(OnceLock::new()); + let _ = slot.set(Ok(pcm)); + let clip = ClipParams { fade_in: 0.5, volume: 0.5, ..Default::default() }; + let mut m = Mixer { + voices: vec![Voice { + key: 1, + media_key: 1, + audio: Some(slot), + spec: VoiceSpec { clip, looping: false, duration: 1.0, audio: true }, + t: 0.0, + state: PlayState::Playing, + }], + }; + let mut buf = vec![0.0f32; 2 * 12]; + m.mix(Some(&mut buf), 12, 2, 10); + // Frame 0: fade starts at 0; frame 3: 0.3/0.5 × 0.5 = 0.3; both channels carry the mono source. + assert_eq!(buf[0], 0.0); + assert!((buf[6] - 0.3).abs() < 1e-5 && (buf[7] - 0.3).abs() < 1e-5, "{buf:?}"); + assert!((buf[16] - 0.5).abs() < 1e-5); + // Past the end: silence, and the voice has ended. + assert_eq!(buf[22], 0.0); + assert_eq!(m.voices[0].state, PlayState::Ended); +} + +#[test] +fn device_clock_drives_position() { + struct Fake(Arc>>>); + impl AudioOut for Fake { + fn start(&mut self, fill: Box) -> Result<(), String> { + *self.0.lock().map_err(|e| e.to_string())? = Some(fill); + Ok(()) + } + fn stop(&mut self) {} + } + let cb = Arc::new(Mutex::new(None)); + let mut p = Player::new(Some(Box::new(Fake(cb.clone())))); + let b: Bytes = Arc::new(vec![]); + p.play(3, 3, &b, spec(10.0, false), None); + // Wall time alone doesn't move a device-clocked voice… + p.tick(0.0); + p.tick(0.2); + assert_eq!(p.status(3).map(|s| s.position), Some(0.0)); + // …the device pulling 4800 frames at 48 kHz does. + let mut buf = vec![0.0; 4800 * 2]; + if let Some(f) = cb.lock().ok().and_then(|mut g| g.take()).as_mut() { + f(&mut buf, 2, 48_000); + } + let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); + assert!((pos - 0.1).abs() < 1e-6, "{pos}"); +} + +/// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. +fn wav_level(secs: f64, amp: f32) -> Bytes { + let rate = 8_000u32; + let n = (rate as f64 * secs).round() as usize; + let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + Arc::new(b) +} + +fn audible(duration: f64) -> VoiceSpec { + VoiceSpec { clip: ClipParams::default(), looping: false, duration, audio: true } +} + +/// First mixed sample of voice `key`, after its audio has decoded. +fn first_sample(p: &Player, key: u64) -> f32 { + assert!(p.wait_loaded(key, std::time::Duration::from_secs(5)), "decode"); + let mut buf = vec![0.0f32; 4]; + p.render(&mut buf, 1, 8_000); + buf[0] +} + +#[test] +fn replacing_the_source_plays_the_new_audio_and_keeps_its_bytes() { + let mut p = Player::new(None); + let loud = wav_level(0.05, 0.5); + let quiet = wav_level(0.10, 0.25); + let loud_key = Arc::as_ptr(&loud) as u64; + let quiet_key = Arc::as_ptr(&quiet) as u64; + assert_ne!(loud_key, quiet_key); + + // Duration 0: the clock takes its length from the decoded audio, so a stale slot would + // report the previous clip. + p.play(1, loud_key, &loud, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.5).abs() < 0.02, "loud sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); + assert!(p.cache.get(&loud_key).is_some_and(|c| Arc::ptr_eq(&c.source, &loud))); + + // The caller's buffer can go away; the cache still owns the allocation its key names. + drop(loud); + assert_eq!(Arc::as_ptr(&p.cache.get(&loud_key).expect("cached").source) as u64, loud_key); + + p.play(1, quiet_key, &quiet, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.25).abs() < 0.02, "quiet sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.10).abs() < 0.01, "quiet duration {dur}"); + assert!(p.cache.get(&quiet_key).is_some_and(|c| Arc::ptr_eq(&c.source, &quiet))); + // Resuming the same source does not jump back to the start. + let pos = p.status(1).map(|s| s.position).unwrap_or(-1.0); + p.play(1, quiet_key, &quiet, audible(0.0), None); + let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); + assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); +} + +type Fill = Box; + +/// An output that starts, then fails or goes quiet on command. +#[derive(Clone, Default)] +struct Flaky { + fill: Arc>>, + fail: Arc>>, + starts: Arc>, +} +impl AudioOut for Flaky { + fn start(&mut self, fill: Fill) -> Result<(), String> { + *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); + *self.starts.lock().map_err(|e| e.to_string())? += 1; + Ok(()) + } + fn stop(&mut self) { + if let Ok(mut f) = self.fill.lock() { + *f = None; + } + } + fn failure(&mut self) -> Option { + self.fail.lock().ok().and_then(|mut f| f.take()) + } +} +impl Flaky { + fn pull(&self, frames: usize) { + let mut buf = vec![0.0; frames * 2]; + if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { + f(&mut buf, 2, 48_000); + } + } + fn starts(&self) -> u32 { + self.starts.lock().map(|s| *s).unwrap_or(0) + } +} + +fn pos(p: &Player, key: u64) -> f64 { + p.status(key).map(|s| s.position).unwrap_or(-1.0) +} + +#[test] +fn a_failed_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.tick(0.0); + out.pull(4800); + assert!((pos(&p, 1) - 0.1).abs() < 1e-6); + if let Ok(mut f) = out.fail.lock() { + *f = Some("unplugged".into()); + } + p.tick(0.2); + p.tick(0.4); + // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then + // the 0.4 s of wall time since the last tick before the failure. + assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); + // Playing again restarts the device. + p.play(2, 2, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 2); + assert!(p.has_output()); +} + +#[test] +fn a_stalled_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + // No callback ever arrives: after the first-callback grace the wall clock takes over. + for i in 0..=21 { + p.tick(i as f64 * 0.25); + } + let before = pos(&p, 1); + assert!(before > 0.0, "{before}"); + p.tick(5.5); + assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); +} + +#[test] +fn a_slow_device_that_answers_within_the_grace_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Two seconds in, the device starts asking for samples: the same stream keeps playing. + out.pull(256); + p.tick(2.25); + p.tick(2.5); + assert_eq!(out.starts(), 1, "the stream was not restarted"); +} + +#[test] +fn an_idle_stream_without_callbacks_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.pause(1); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Resuming keeps the running stream: no restart. + p.play(1, 1, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 1); +} + +#[test] +fn repeated_failures_give_up_on_the_device() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + let mut now = 0.0; + for _ in 0..MAX_OUTPUT_FAILURES { + p.play(1, 1, &b, spec(100.0, false), None); + if let Ok(mut f) = out.fail.lock() { + *f = Some("broken".into()); + } + p.tick(now); + now += 0.1; + } + assert!(!p.has_output()); + assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); + // Still plays, silently on wall time. + let before = pos(&p, 1); + p.tick(now + 0.2); + assert!(pos(&p, 1) > before); +} From d9199598e12e19d98c9360cad242a6592d55ac80 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:28:58 +0000 Subject: [PATCH 21/90] refactor(ui-egui): move SlideApp methods to app.rs and lib.rs types to services.rs, state.rs, clock.rs, ime_hooks.rs --- crates/ui-egui/src/app.rs | 2833 +++++++++++++++++++++++++++ crates/ui-egui/src/clock.rs | 16 + crates/ui-egui/src/ime_hooks.rs | 39 + crates/ui-egui/src/lib.rs | 3153 +------------------------------ crates/ui-egui/src/services.rs | 59 + crates/ui-egui/src/state.rs | 202 ++ 6 files changed, 3162 insertions(+), 3140 deletions(-) create mode 100644 crates/ui-egui/src/app.rs create mode 100644 crates/ui-egui/src/clock.rs create mode 100644 crates/ui-egui/src/ime_hooks.rs create mode 100644 crates/ui-egui/src/services.rs create mode 100644 crates/ui-egui/src/state.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 000000000..0dcd5df03 --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,2833 @@ +use super::clock::{monotonic_secs, now_ms}; +use super::ime_hooks::ime_in_ui_pass_key; +use super::services::{InboxEvent, Services}; +use super::state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp, LocatedPlace, RECENT_MAX, UiState, ViewMode}; +use super::*; + +fn purpose_of(purpose: &str) -> FilePurpose { + match purpose { + "open" => FilePurpose::Open, + "picture" => FilePurpose::Picture, + "audio" => FilePurpose::Audio, + "video" => FilePurpose::Video, + _ => FilePurpose::Infer, + } +} + +fn is_presentation_name(lower: &str) -> bool { + lower.ends_with(".deckcraft") + || lower.ends_with(".slidecraft") + || lower.ends_with(".pptx") + || lower.ends_with(".potx") + || lower.ends_with(".ppsx") +} + +/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. +fn open_command(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + Some("file.openBytes") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { + Some("slide.fromOutline") + } else { + None + } +} + +/// A drop has no requested operation, so the name and bytes decide: a presentation opens, +/// then a recognised picture, sound or video is inserted, then an outline becomes slides. +fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + return Some("file.openBytes"); + } + let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); + if ct.starts_with("image/") { + Some("insert.picture") + } else if ct.starts_with("audio/") { + Some("insert.audio") + } else if ct.starts_with("video/") { + Some("insert.video") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { + Some("slide.fromOutline") + } else { + None + } +} + +fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { + let cmd = match purpose { + FilePurpose::Picture => Some("insert.picture"), + FilePurpose::Audio => Some("insert.audio"), + FilePurpose::Video => Some("insert.video"), + FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), + FilePurpose::Open => open_command(name), + FilePurpose::Infer => infer_command(name, bytes), + }; + cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) +} + +/// How long a transient status message stays visible. +const STATUS_LINGER_MS: f64 = 6000.0; + +/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) +/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the +/// shortcut never reached the session and only the context menu could paste objects (#182). An +/// invisible word joiner, so a paste into another app adds nothing visible. +pub(crate) const OBJECTS_MARKER: &str = "\u{2060}"; + +/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. +pub(crate) fn system_clipboard_text(t: &str) -> String { + if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } +} + +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + +impl SlideApp { + /// Build application UI state, taking ownership of audio output and initialising media, dialogs, + /// synthetic input, and timing fields. + pub fn new(session: Session, mut services: Services) -> Self { + let audio_out = services.audio_out.take(); + SlideApp { + last_fill: serde_json::json!("accent1"), + last_outline: serde_json::json!("tx1"), + table_pen: serde_json::json!({"width": 1.0}), + session, + services, + ui: UiState::default(), + textures: textures::Textures::default(), + control_rx: None, + squiggles: Default::default(), + dialog: None, + thesaurus_query: String::new(), + show: None, + anim_preview: None, + reuse_target: None, + media: media::MediaHost::new(audio_out), + status: None, + opening: None, + toast: None, + fonts_noted: Default::default(), + perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, + synthetic: vec![], + synthetic_pending: false, + synthetic_next: vec![], + synthetic_next_target: None, + synthetic_injected: vec![], + synthetic_injected_at: None, + synthetic_generation: 0, + synthetic_target: None, + synthetic_focus: None, + synthetic_injected_time: None, + ribbon_scroll: HashMap::new(), + show_instance: 0, + opening_generation: 0, + canvas_rect: None, + eyedropper: None, + canvas_scale: 1.0, + slide_rect: None, + canvas_region: None, + palette: None, + integrated_titlebar: false, + backstage: None, + pending_urls: vec![], + #[cfg(not(target_arch = "wasm32"))] + updates_rt: updates_ui::Runtime::default(), + pending_copy: None, + capture: capture::Capture::default(), + styled: false, + restyle: true, + fonts_ready: false, + last_time: 0.0, + last_recovery: 0.0, + quit_confirmed: false, + notes_buf: (0, usize::MAX, notes::Draft::default()), + ui_fonts: uifonts::UiFonts::default(), + thumb_drag: None, + selpane: selpane::State::default(), + align_to: None, + doc_window: None, + sorter: sorter::State::default(), + slides_focused: false, + v_seen: false, + recent_checked: HashMap::new(), + twisting: false, + next_file_id: 1, + pending_files: Vec::new(), + ime_tracker: egui::ime_context::ImeContextTracker::new(), + ime_pending: None, + ime_frame_viewport: None, + ime_viewport: None, + ime_composition: None, + ime_terminal_pending: None, + ime_cancelled: None, + ime_generation: 0, + ime_settles_seen: 0, + ime_interrupt_pending: false, + ime_reset: HashMap::new(), + } + } + + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + pub fn set_status(&mut self, msg: impl Into) { + self.status = Some((msg.into(), now_ms())); + } + + /// Turn on the automatic check for a newer release at start-up (desktop shell only; tests and + /// the web build never touch the network on their own). + #[cfg(not(target_arch = "wasm32"))] + pub fn enable_update_checks(&mut self) { + updates_ui::enable(self); + } + + /// A message that must be seen wherever the user is: a toast, which also shows over the start + /// screen and the File tab, where there is no status bar. + pub fn notify(&mut self, msg: impl Into) { + self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() }); + } + + /// The transient status message while it is fresh; clears it once stale. Both the status bar + /// and the start screen draw it — the start screen has no status bar, and errors raised there + /// (a missing recent file, a failed sample) used to vanish (issue #172). + pub fn fresh_status(&mut self) -> Option { + let (msg, at) = self.status.clone()?; + if now_ms() - at < STATUS_LINGER_MS { + Some(msg) + } else { + self.status = None; + None + } + } + + /// Does this recent path exist on disk? Probed once per path per session and only on + /// desktop (the web build has no file system to ask): a stat on a dead network share can + /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — + /// opening still tries and reports, and a successful open re-probes. + pub fn recent_exists(&mut self, path: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = path; + true + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ok) = self.recent_checked.get(path) { + return *ok; + } + // A stat on a dead network location (UNC share, URL) can block for the OS timeout, + // so remote-looking paths are never probed: they stay unmarked and are judged when + // opened. A disconnected mapped drive would still stall once — accepted. + if path.starts_with("\\\\") || path.contains("://") { + return true; + } + let ok = std::path::Path::new(path).is_file(); + if self.recent_checked.len() > 64 { + let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); + self.recent_checked.retain(|p, _| live.contains(p)); + } + self.recent_checked.insert(path.to_string(), ok); + ok + } + } + + /// Drop a path from the Recent list (its file is gone or the user asked for it). + pub fn forget_recent(&mut self, path: &str) { + self.ui.recent.retain(|p| p != path); + self.recent_checked.remove(path); + } + + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the + /// status bar and are returned. + pub fn run(&mut self, id: &str, params: Value) -> Result { + let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { + self.run_ui(id, ¶ms) + } else { + self.session.execute(id, ¶ms).map_err(|e| e.to_string()) + }; + match &r { + // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel + // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). + Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { + if let Some(t) = v.get("text").and_then(Value::as_str) { + self.pending_copy = Some(t.to_string()); + } + } + Ok(_) => {} + Err(e) => self.set_status(e.clone()), + } + self.drain_requests(); + r + } + + /// Return the current document count plus the active document uid, so callers can detect a + /// replacement document rather than only an index change. + pub(crate) fn docs_key(&self) -> (usize, Option) { + // The document uid, not just its index: closing one presentation and opening another + // must not look like the same document to the Backstage view or a queued input batch. + (self.session.documents().len(), self.session.active().map(|d| d.uid)) + } + + /// What a queued synthetic event is meant for. The control protocol hands out window + /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and + /// the next UI pass makes the event stale. + pub(crate) fn input_target(&self) -> InputTarget { + let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; + InputTarget { + documents: self.session.documents().len(), + active: self.session.active().map(|d| d.uid), + slide: self.session.active().map(|d| d.selection.slide), + view: self.ui.view, + zoom: self.ui.zoom.map(bits), + canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), + canvas_scale: bits(self.canvas_scale), + dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), + palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), + backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), + ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), + ribbon_collapsed: self.ui.ribbon_collapsed, + ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), + show: self.show.as_ref().map(show::Show::input_state), + opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), + } + } + + /// Queue one synthetic control-channel event. The target is recorded when the batch starts, + /// before any host command in the same logic pass can change the document. + pub fn queue_input(&mut self, event: egui::Event) { + let target = self.input_target(); + // A batch that was already injected keeps the target it was injected for until its UI + // pass consumes it or retracts it. New events queued meanwhile belong to the current + // target, so they wait in a separate batch instead of joining the old one and being + // cleared with it when that batch turns out to be stale. + if self.synthetic_pending { + if self.synthetic_next_target != Some(target) { + self.synthetic_next.clear(); + self.synthetic_next_target = Some(target); + } + self.synthetic_next.push(event); + return; + } + if self.synthetic.is_empty() { + self.synthetic_target = Some(target); + } else if self.synthetic_target != Some(target) { + // The unsent queue belongs to a target that has already changed; drop it (and any + // older batch still waiting behind it) rather than appending a click intended for the + // new target to a stale batch. + self.synthetic.clear(); + self.synthetic_next.clear(); + self.synthetic_next_target = None; + self.synthetic_target = Some(target); + } + self.synthetic.push(event); + } + + /// Events admitted by the control channel but not yet injected (including a batch waiting + /// behind the one currently in flight). + pub(crate) fn synthetic_queued_len(&self) -> usize { + self.synthetic.len() + self.synthetic_next.len() + } + + /// The File tab's view is open over a presentation (not just the start screen with nothing + /// open). + pub fn backstage_open(&self) -> bool { + self.backstage.is_some() && self.session.active().is_some() + } + + /// Opens the File tab's view on `page` (keeping its state when it is already open). + pub fn open_backstage(&mut self, page: backstage::Page) { + let key = self.docs_key(); + match &mut self.backstage { + Some(b) => b.go(page), + None => self.backstage = Some(backstage::Backstage::new(page, key)), + } + } + + /// Back to the presentation (no effect on the start screen, where nothing is open). + pub fn close_backstage(&mut self) { + if self.session.active().is_some() { + self.backstage = None; + } + } + + /// Keeps the File tab's view in step with the open presentations: it closes when one is + /// opened, created or closed (the result shows), and it is the start screen when none is open. + fn sync_backstage(&mut self) { + let key = self.docs_key(); + let has_doc = self.session.active().is_some(); + let stale = self.backstage.as_ref().is_some_and(|b| b.docs != key); + if has_doc && stale { + self.backstage = None; + } else if !has_doc && (stale || self.backstage.is_none()) { + self.backstage = Some(backstage::Backstage::new(backstage::Page::Home, key)); + } + } + + /// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved + /// changes (Save, Don't Save, Cancel), as PowerPoint does. + pub fn close_presentation(&mut self) { + match self.session.active() { + None => {} + Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")), + Some(_) => { + let _ = self.run("file.close", json!({})); + } + } + } + + /// The file name a save or export suggests: the presentation's title with `ext`. + fn suggested_name(&self, ext: &str) -> String { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + format!("{name}.{ext}") + } + + /// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`, + /// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download + /// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The + /// presentation keeps its own file. Returns whether a file was written or handed over. + pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool { + let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false }; + let (ext, format, all) = match fmt { + "pdf" => ("pdf", "pdf", false), + "png" => ("png", "png", false), + "pngAll" => ("png", "png", true), + "jpeg" | "jpg" => ("jpg", "jpeg", false), + "pptx" => ("pptx", "pptx", false), + "outline" | "txt" => ("txt", "outline", false), + "rtf" => ("rtf", "rtf", false), + "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), + other => { + self.set_status(format!("DeckCraft can't export “{other}”")); + return false; + } + }; + let suggested = self.suggested_name(ext); + if let Some(pick) = self.services.pick_save.as_mut() { + let Some(path) = pick(&suggested) else { return false }; + let mut params = json!({"path": path, "format": format, "all": all}); + if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) { + o.extend(x.clone()); + } + return match self.run("file.export", params) { + Ok(_) => { + self.set_status(format!("Exported {path}")); + true + } + Err(_) => false, + }; + } + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; + let bytes = match format { + "png" | "jpeg" => { + // Every slide for "all slides", one download each, named like the desktop export (#64). + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let mut any = false; + let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { + any = self.download(&file, Ok(bytes)) || any; + } + return any; + } + "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), + other => deckcraft_engine::cmd::file::save_bytes(&doc, other), + }; + self.download(&suggested, bytes) + } + + /// Hands `bytes` to the browser as a download (web); false (with the reason in the status + /// bar) when there are no bytes or no way to download. + fn download(&mut self, name: &str, bytes: deckcraft_engine::Result>) -> bool { + match (bytes, self.services.download.as_mut()) { + (Ok(b), Some(dl)) => { + dl(name, &b); + true + } + (Err(e), _) => { + self.set_status(e.to_string()); + false + } + (Ok(_), None) => { + self.set_status("Saving a copy needs a file picker, which this window doesn't have."); + false + } + } + } + + /// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's + /// layout, slides and options) is written to the temporary folder and opened in the system's + /// PDF viewer, which has the printers; on the web it downloads. + pub fn print_pdf(&mut self, params: Value) -> bool { + let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false }; + let mut params = if params.is_object() { params } else { json!({}) }; + if self.services.pick_save.is_none() { + // Web: the browser hands the PDF to the user. + let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); + return self.download(&format!("{title}.pdf"), bytes); + } + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(path)); + o.insert("format".into(), json!("pdf")); + } + match self.run("file.export", params) { + Ok(_) => { + self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); + self.set_status("Opened the presentation as a PDF to print"); + true + } + Err(_) => false, + } + } + + fn flag(&mut self, params: &Value, cur: bool) -> bool { + params.get("on").and_then(Value::as_bool).unwrap_or(!cur) + } + + fn run_ui(&mut self, id: &str, p: &Value) -> Result { + match id { + "view.normal" => self.ui.view = ViewMode::Normal, + "view.outline" => self.ui.view = ViewMode::Outline, + "view.sorter" => self.ui.view = ViewMode::Sorter, + "view.notesPage" => self.ui.view = ViewMode::NotesPage, + "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), + "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), + "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), + "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } + "view.zoomIn" | "view.zoomOut" => { + let cur = self.ui.zoom.unwrap_or(self.canvas_scale); + let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + let next = if id == "view.zoomIn" { + steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) + } else { + steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) + }; + self.ui.zoom = Some(next); + } + "view.zoom" => { + let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; + self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); + } + "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), + "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } + "view.pane" => { + let pane = p.get("pane").and_then(Value::as_str).map(String::from); + self.ui.pane = + if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; + if let Some(tab) = p.get("tab").and_then(Value::as_str) { + self.ui.format_tab = tab.to_string(); + } + } + "view.tab" => { + let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; + self.ui.tab = tab.to_string(); + self.ui.ribbon_collapsed = false; + } + "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, + "view.dark" => { + let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); + self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; + self.restyle = true; + } + "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), + "view.newWindow" => return doc_window::command(self, p), + "show.start" => { + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + self.start_custom_show(name, false); + } else { + let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); + self.start_show(from, false); + } + } + "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), + "show.end" => { + self.show = None; + self.media.stop_owned(media::Owner::Show); + } + "animation.preview" => { + if p.get("on").and_then(Value::as_bool) == Some(false) { + self.anim_preview = None; + return Ok(json!({"playing": false})); + } + let from = match p.get("from") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), + }; + return self.start_anim_preview(from); + } + "app.home" => { + let on = self.flag(p, self.backstage_open()); + if on { + self.open_backstage(backstage::Page::Home); + } else { + self.close_backstage(); + } + } + "app.backstage" => { + if !p.get("on").and_then(Value::as_bool).unwrap_or(true) { + self.close_backstage(); + return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})); + } + let page = match p.get("page").and_then(Value::as_str) { + Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?, + None => backstage::Page::Home, + }; + if page.needs_doc() && self.session.active().is_none() { + return Err(format!("File › {} needs an open presentation", page.label())); + } + self.open_backstage(page); + return Ok(json!({"page": page.id()})); + } + "app.closePresentation" => self.close_presentation(), + "app.print" => { + if self.session.active().is_none() { + return Err("nothing to print: no presentation is open".into()); + } + return Ok(json!({"printed": self.print_pdf(p.clone())})); + } + "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), + "app.checkUpdates" => { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::request_check(self); + #[cfg(target_arch = "wasm32")] + return Err("updates are handled by the browser: reload the page".into()); + } + "app.palette" => self.palette = Some((String::new(), 0)), + "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), + "app.openDialog" => self.pick_and_open(), + "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.smartArtPictureDialog" => { + if self.services.pick_open.is_none() { + return Err("picking a SmartArt picture needs the desktop app".into()); + } + self.pick_and_run("picture", "smartArt.picture", p); + } + "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), + "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), + "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), + "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), + "app.saveAsDialog" => self.save_as_dialog("deckcraft"), + "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), + "app.browseThemeDialog" => { + if self.session.active().is_none() { + return Err("open a presentation first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("theme", "design.browseTheme"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Browse for Themes needs the desktop app for now".to_string()); + } + } + "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } + "app.dialog" => { + let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; + self.dialog = Some(dialogs::Dialog::new(d)); + } + "app.links" => { + use deckcraft_engine::links::*; + return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); + } + _ => return Err(format!("unknown UI command `{id}`")), + } + Ok(Value::Null) + } + + /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the + /// thumbnail pane has been clicked. + pub(crate) fn slides_list_focused(&self) -> bool { + self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) + } + + /// First slide Play from Start should open: the custom show or range, skipping hidden slides. + pub fn show_start_index(&self) -> usize { + self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) + } + + /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of + /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or + /// opens on the end screen. Hidden slides are allowed, as in "from current slide". + pub(crate) fn show_from(&self, p: &Value) -> Result, String> { + let from = match p.get("from") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, + }; + let count = self.session.active().map_or(0, |d| d.doc.slides.len()); + if from >= count { + return Err(format!("no slide {from}")); + } + Ok(Some(from)) + } + + /// Leave animation preview, stop editor-owned media, and create a show at the requested slide + /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. + pub fn start_show(&mut self, from: usize, reading: bool) { + self.anim_preview = None; + self.media.stop_owned(media::Owner::Editor); + if let Some(d) = self.session.active() { + self.show_instance = self.show_instance.wrapping_add(1).max(1); + let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); + show.instance = self.show_instance; + self.show = Some(show); + } + } + + /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. + pub fn start_custom_show(&mut self, name: &str, reading: bool) { + let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); + let Some(state) = state else { return }; + let from = state.slide; + self.start_show(from, reading); + if let Some(show) = self.show.as_mut() { + show.state = state; + } + } + + /// The current slide's animations are playing on the editor canvas. + pub fn anim_preview_active(&self) -> bool { + self.anim_preview.is_some() + } + + /// `(slide, seconds played, sequence length)` while a preview is running. + pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { + self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) + } + + pub fn stop_anim_preview(&mut self) { + self.anim_preview = None; + } + + /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. + pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { + self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) + } + + /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence + /// (plus a short hold) has finished. + pub fn tick_anim_preview(&mut self, now: f64) -> bool { + let Some(p) = self.anim_preview.as_ref() else { return false }; + let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); + let alive = self.show.is_none() + && self.session.active().is_some_and(|d| { + d.uid == uid + && d.selection.target == deckcraft_engine::Target::Slides + && d.selection.slide == slide + && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) + }); + if !alive { + self.anim_preview = None; + return false; + } + let Some(p) = self.anim_preview.as_mut() else { return false }; + let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; + let start = *p.started.get_or_insert(now); + let elapsed = p.from + (now - start).max(0.0); + if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { + self.anim_preview = None; + return false; + } + p.elapsed = elapsed; + true + } + + /// Play the current slide's animations on the canvas, from effect `from` (index into the slide's + /// animations) or from the start. + fn start_anim_preview(&mut self, from: Option) -> Result { + let (uid, slide, ptr, timeline) = { + let Some(d) = self.session.active() else { + return Err("no presentation is open".into()); + }; + if d.selection.target != deckcraft_engine::Target::Slides { + return Err("animation preview plays the current slide".into()); + } + let slide = d.selection.slide; + let Some(slide_arc) = d.doc.slides.get(slide) else { + return Err("no slide to preview".into()); + }; + let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; + let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); + (d.uid, slide, ptr, timeline) + }; + if timeline.is_empty() { + self.anim_preview = None; + self.set_status("This slide has no animations"); + return Ok(json!({"playing": false})); + } + let from_secs = match from { + Some(i) => timeline.start_of(i).ok_or_else(|| format!("no effect {i} in this slide's click sequence"))?, + None => 0.0, + }; + if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { + self.ui.view = ViewMode::Normal; + } + self.close_backstage(); + let duration = timeline.total_duration(); + self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); + self.set_status("Previewing animations"); + Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) + } + + pub(crate) fn pick_and_open(&mut self) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick("open") { + self.open_path_in_background(&path); + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file("open"); + if let Some(f) = self.services.open_async.as_mut() { + f("open", id); + } + } + } + + pub fn open_path(&mut self, path: &str) -> Result<(), String> { + self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.note_recent(path); + Ok(()) + } + + /// Files the system hands over (the command line, a double-click through the file + /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide + /// show starts at once from where Play from Start begins, as PowerPoint does; anything else + /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that + /// couldn't be opened, with the reason. + pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { + let mut failed = Vec::new(); + let mut last = None; + for p in paths { + match self.open_path(p) { + Ok(()) => last = Some(p), + Err(e) => failed.push((p.clone(), e)), + } + } + if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { + self.start_show(self.show_start_index(), false); + } + failed + } + + /// A presentation was opened from `path`: first in the recent list, shown in Normal view. + pub(crate) fn note_recent(&mut self, path: &str) { + self.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(RECENT_MAX); + self.ui.view = ViewMode::Normal; + } + + /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. + pub(crate) fn pick_transition_sound(&mut self) { + if self.services.pick_open.is_some() { + self.pick_and_insert("audio", "transition.sound"); + } + } + + /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the + /// current one (desktop only: the browser picker opens what it picks as a document). + fn pick_and_reuse(&mut self, keep: bool) { + let Some(pick) = self.services.pick_open.as_mut() else { + self.set_status("Reuse Slides needs the desktop app".to_string()); + return; + }; + let Some(path) = pick("open") else { return }; + // Read the file once: the list shown and the slides inserted come from the same bytes, even + // if the file changes while the dialog is open. Without a reader the path is used as is. + let mut source = json!({}); + match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + source["name"] = json!(name); + source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + } + Some(Err(e)) => { + self.set_status(e.to_string()); + return; + } + None => source["path"] = json!(path), + } + // Offer the file's slides to choose from; all of them are ticked to begin with. + match self.session.execute("slide.reuseList", &source) { + Ok(list) => { + let mut d = dialogs::Dialog::new("reuseSlides"); + d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]}); + self.reuse_target = Some(self.capture_file_target()); + self.dialog = Some(d); + } + Err(e) => self.set_status(e.to_string()), + } + } + + /// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that + /// `slide.reuseList` listed) after the slide that was current when the picker opened, in that + /// presentation, even if another one is active now; if it is closed or the slide is gone, + /// nothing is inserted. Returns whether they were inserted. + pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool { + let mut args = if source.is_object() { source.clone() } else { json!({}) }; + args["keepFormatting"] = json!(keep); + args["slides"] = json!(picked); + let result = match self.reuse_target { + Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args), + _ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()), + }; + match result { + Ok(()) => { + self.reuse_target = None; + self.set_status(format!("Inserted {} slides", picked.len())); + true + } + Err(e) => { + self.set_status(e); + false + } + } + } + + pub(crate) fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + self.pick_and_run(purpose, cmd, &json!({})); + } + + /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. + fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick(purpose) { + let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; + let r = match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + args["name"] = json!(name); + args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + self.session.execute(cmd, &args) + } + _ => { + args["path"] = json!(path); + self.session.execute(cmd, &args) + } + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file(purpose); + // Change Picture: the picture that arrives replaces the selected one (#115). + if cmd == "picture.change" + && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) + { + r.purpose = FilePurpose::ChangePicture; + } + if let Some(f) = self.services.open_async.as_mut() { + f(purpose, id); + } + } + } + + pub fn save_as_dialog(&mut self, format: &str) { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let ext = if format == "pptx" { "pptx" } else { "deckcraft" }; + let suggested = format!("{name}.{ext}"); + if let Some(pick) = self.services.pick_save.as_mut() { + if let Some(path) = pick(&suggested) { + match self.session.execute("file.saveAs", &json!({"path": path})) { + Ok(_) => { + self.set_status(format!("Saved {path}")); + self.ui.recent.retain(|p| *p != path); + self.ui.recent.insert(0, path); + } + Err(e) => self.set_status(e.to_string()), + } + } + } else if let Some(dl) = self.services.download.as_mut() + && let Some(d) = self.session.active() + { + match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) { + Ok(bytes) => dl(&suggested, &bytes), + Err(e) => self.set_status(e.to_string()), + } + } + } + + /// Save every presentation with unsaved changes (asking for a location where needed). + /// Returns false if one wasn't saved (cancelled or failed). + pub fn save_all(&mut self) -> bool { + let dirty: Vec = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect(); + for i in dirty { + self.session.set_active(i); + self.save(); + if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) { + return false; + } + } + true + } + + pub fn save(&mut self) { + if self.session.active().and_then(|d| d.path.clone()).is_some() { + match self.session.execute("file.save", &json!({})) { + Ok(_) => self.set_status("Saved"), + Err(e) => self.set_status(e.to_string()), + } + } else { + self.save_as_dialog("deckcraft"); + } + } + + /// Act on requests commands left for the UI. + fn drain_requests(&mut self) { + for r in std::mem::take(&mut self.session.ui_requests) { + match r { + UiRequest::Dialog { id, params } => { + let mut d = dialogs::Dialog::new(&id); + d.params = params; + self.dialog = Some(d); + } + UiRequest::Pane { id } => self.ui.pane = Some(id), + UiRequest::StartShow { from } => self.start_show(from, false), + UiRequest::Message { text } => self.set_status(text), + UiRequest::PickFile { purpose } => { + if purpose == "saveAs" { + self.save_as_dialog("deckcraft"); + } + } + UiRequest::Media { action, shape, ms } => { + let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; + // The command resolved `shape` on the editor's slide, also while a show runs. + if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { + self.media.request(uid, &doc, slide, &action, shape, ms, owner); + } + } + } + } + } + + pub(crate) fn drain_inbox(&mut self) { + let Some(inbox) = self.services.inbox.clone() else { return }; + let events = match inbox.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), + }; + for event in events { + match event { + InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), + InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), + } + } + } + + /// Remember the open presentation, the slide (or master/layout) and the operation for a + /// file the host will read asynchronously. Returns the cancellation identity the host must + /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the + /// bytes arrive. + pub fn begin_external_file(&mut self, purpose: &str) -> u64 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); + id + } + + /// A dropped or picked file read synchronously: presentations open, media is inserted on + /// the slide that is current now. + pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { + let id = self.begin_external_file("drop"); + self.apply_external_file(id, name, bytes); + } + + fn capture_file_target(&self) -> FileTarget { + let Some(d) = self.session.active() else { return FileTarget::Absent }; + let place = match d.selection.target { + Target::Slides => match d.current_slide() { + Some(s) => FilePlace::Slide(s.id), + None => FilePlace::Unspecified, + }, + Target::Master { master } => match d.doc.masters.get(master) { + Some(m) => FilePlace::Master(m.id), + None => FilePlace::Unspecified, + }, + Target::Layout { master, layout } => match d.doc.masters.get(master) { + Some(m) => match m.layouts.get(layout) { + Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, + None => FilePlace::Unspecified, + }, + None => FilePlace::Unspecified, + }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), + }; + FileTarget::Document { doc: d.uid, place } + } + + /// True when the user is still looking at the place this request captured. + fn viewing_target(&self, target: &FileTarget) -> bool { + let FileTarget::Document { doc, place } = target else { return false }; + let Some(d) = self.session.active() else { return false }; + if d.uid != *doc { + return false; + } + match place { + FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), + FilePlace::Master(id) => { + matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) + } + FilePlace::Layout { master, layout } => { + matches!(d.selection.target, Target::Layout { master: mi, layout: li } + if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) + } + FilePlace::NotesPage(t) => d.selection.target == *t, + FilePlace::Unspecified => false, + } + } + + fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { + let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; + let req = self.pending_files.remove(pos); + let cmd = match command_for(req.purpose, name, bytes) { + Ok(cmd) => cmd, + Err(e) => { + self.set_status(e); + return; + } + }; + let params = if cmd == "slide.fromOutline" { + json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) + } else { + json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) + }; + let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { + self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) + } else { + self.execute_on_target(&req.target, name, cmd, ¶ms) + }; + match result { + Err(e) if cmd == "file.openBytes" => self.notify(e), + Err(e) => self.set_status(e), + Ok(()) => {} + } + self.drain_requests(); + } + + /// Run `cmd` on the document and slide captured for the request, then put the view back + /// if the user has since moved. A missing document or slide cancels the file. + fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { + let FileTarget::Document { doc, place } = target else { + self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; + return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + }; + let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { + return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); + }; + let view = self.session.active_index(); + let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); + self.session.set_active(idx); + let located = match self.locate_place(place) { + Ok(located) => located, + Err(e) => { + self.restore_file_view(view); + return Err(format!("{name} was not applied: {e}")); + } + }; + if let Some(st) = self.session.active_mut() { + st.selection.target = located.target; + if let Some(slide) = located.slide { + st.selection.slide = slide; + st.selection.slides.clear(); + } + st.selection.shapes.clear(); + st.selection.text = None; + st.selection.cells = None; + } + let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + if view == Some(idx) { + if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { + st.selection = sel; + } + } else { + self.restore_file_view(view); + } + result + } + + fn restore_file_view(&mut self, view: Option) { + if let Some(i) = view + && i < self.session.documents().len() + { + self.session.set_active(i); + } + } + + fn locate_place(&self, place: &FilePlace) -> Result { + let d = self.session.doc().map_err(|e| e.to_string())?; + match place { + FilePlace::Slide(id) => { + let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) + } + FilePlace::Master(id) => { + let master = + d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Master { master }, slide: None }) + } + FilePlace::Layout { master, layout } => { + let mi = + d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + let li = d + .doc + .masters + .get(mi) + .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) + .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) + } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), + FilePlace::Unspecified => Err("there was no slide to put it on".into()), + } + } + + /// Keep a hidden show playing from logic alone. `run_logic` does not refresh + /// `InputState::time`, so the show anchors its last visible time to this monotonic instant + /// and advances from there. A forced hidden paint draws the current state but does not + /// advance it a second time, and no paused-show product policy is invented. + pub(crate) fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { + let visible = show::viewport_visible(ctx); + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; + let Some(show) = self.show.as_mut() else { return }; + if visible { + // A visible logic-only pass (`run_logic`) carries the last full frame's input time. + // Anchoring that stale time to `now` would move the hidden clock backwards if the + // window hides before the next full pass. `show::ui` already records the anchor on + // every visible full pass, where `InputState::time` is current. + return; + } + if !show.hidden_started() { + return; + } + let input_time = ctx.input(|i| i.time); + let show_time = show.hidden_logic_time(input_time, now); + let keep = show.advance_hidden(&doc, show_time); + let time_driven = show.hidden_time_driven(&doc); + if !keep { + self.show = None; + return; + } + if time_driven { + // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the + // logic clock ticking; with no paint request pending eframe services it as a + // logic-only pass, not a visible frame. + ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); + } + } + + /// Background work, run on every logic call (visible, hidden or `run_logic`). + /// + /// Only consumed queues and monotonic deadlines live here: control requests, asynchronous + /// opening/inbox results, screenshot completions/deadlines, AutoRecover pacing and media + /// clocks. UI events are handled in [`Self::ui`], where a forced hidden paint also arrives. + pub fn logic(&mut self, ctx: &egui::Context) { + if !Self::root_viewport_admitted(ctx) { + log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id()); + return; + } + self.tick_hidden_show(ctx, web_time::Instant::now()); + // `raw_input_hook` runs before `App::logic`. In a full pass egui has already built + // `PointerState` and `keys_down` from every injected event, so retracting the events after + // this pass would not stop the derived click or key from reaching the UI. Defer instead: + // a control request, background-open result or inbox file that changes the document, + // target or modal owner waits until the admitted batch has been consumed by the old + // target's UI pass. A logic-only callback carries the RawInput into the next full pass + // (no live `InputState` here), so that pass still drains normally and the next + // `raw_input_hook` retracts a batch that went stale. + if !self.synthetic_batch_is_live(ctx) { + self.drain_control(ctx); + self.poll_opening(); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); + // Inbox results are host file bytes and do not need fonts or a painted frame. A + // window that starts hidden must still apply a file picked before its first UI pass. + self.drain_inbox(); + } + self.drain_screenshots(); + // `raw_input_hook` ran before this logic pass. This is normally a no-op now that + // target-changing work is deferred for a live batch; it still covers a target change + // made by other logic, and keeps a logic-only batch pending for the next hook. + self.retract_synthetic_after_logic(ctx); + // Queued/settling screenshots force the bounded hidden paints they need; issued ones + // rely on the screenshot command and the completion callback, with only a deadline wake. + self.capture.request_paint_if_needed(ctx, web_time::Instant::now()); + if !self.synthetic.is_empty() || !self.synthetic_next.is_empty() || self.synthetic_pending { + ctx.request_repaint(); + } + // Closing the window with unsaved changes asks first. + if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && self.session.documents().iter().any(|d| d.is_dirty()) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.show = None; + if self.dialog.as_ref().is_none_or(|d| d.id != "quit") { + self.dialog = Some(dialogs::Dialog::new("quit")); + } + } + self.autosave_if_due(ctx); + self.tick_media_clock(ctx); + self.note_settled_composition(ctx); + // In a full run_ui pass the UI callback after this logic half still owns the pass's final + // IME descriptor. Only a standalone run_logic callback proves that no custom editor was + // painted for this tick. Do not infer this from cumulative frame counters or stale + // geometry. + if !self.ime_in_ui_pass(ctx) { + self.finalize_ime_logic(ctx); + } + } + + /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because + /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. + fn ui_setup(&mut self, ctx: &egui::Context) -> bool { + let scale = self.ui.ui_scale.clamp(0.5, 3.0); + if (ctx.zoom_factor() - scale).abs() > 1e-3 { + ctx.set_zoom_factor(scale); + } + if !self.styled { + theme::install_fonts_with(ctx, &self.ui_fonts.faces); + self.styled = true; + self.restyle = true; + // Font definitions take effect at the next pass boundary. Do not paint a language + // or symbol with the old font set while the new set is still pending. + ctx.request_discard("install interface fonts before layout"); + return false; + } + if self.restyle { + theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); + self.restyle = false; + } + self.fonts_ready = true; + true + } + + /// AutoRecover on a monotonic deadline, even when nothing is painted. + fn autosave_if_due(&mut self, ctx: &egui::Context) { + if self.session.recovery_dir.is_none() { + return; + } + let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); + let now = monotonic_secs(); + if now - self.last_recovery > every { + self.last_recovery = now; + if self.session.documents().iter().any(|d| d.is_dirty()) + && let Err(e) = self.session.execute("file.recovery.save", &json!({})) + { + self.set_status(format!("Couldn't save AutoRecover information: {e}")); + } + } + if self.session.documents().iter().any(|d| d.is_dirty()) { + let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } + + /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real + /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. + fn ui_end(&mut self, ctx: &egui::Context) { + self.ime_frame_viewport = None; + self.capture.observe_ui(ctx); + if !ctx.will_discard() { + let doc = self.session.active().map(|d| d.uid); + self.capture.issue(ctx, doc, web_time::Instant::now()); + } + if self.fonts_ready && !ctx.will_discard() { + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + } + self.promote_next_synthetic(); + } + if self.fonts_ready && !ctx.will_discard() { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + } + } + + /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open + /// presentations' names and text, recent files, and what is typed or composed this frame. + fn note_ui_text(&mut self, ctx: &egui::Context) { + let typed: String = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), + _ => None, + }) + .collect() + }); + self.ui_fonts.note(ctx, &typed); + for d in self.session.documents() { + self.ui_fonts.note(ctx, &d.title()); + } + for r in &self.ui.recent { + self.ui_fonts.note(ctx, r); + } + if let Some(st) = self.session.active() { + self.ui_fonts.note_doc(ctx, st); + } + self.ui_fonts.apply(ctx); + } + + /// Stable IME owner for the canvas text target: document, view, shape and table cell. + /// + /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary + /// edits and caret movement keep the same owner. A new document, shape or cell is a new + /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key + /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is + /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. + pub(crate) fn ime_target(&self) -> Option { + let st = self.session.active()?; + let (shape, cell, notes) = match &st.selection.text { + Some(t) => (t.shape, t.cell, t.notes), + None => (*st.selection.shapes.first()?, None, false), + }; + if st.selection.text.is_none() && st.selection.shapes.len() != 1 { + return None; + } + let target = match st.selection.target { + Target::Slides => (0u8, 0usize, 0usize), + Target::Master { master } => (1, master, 0), + Target::Layout { master, layout } => (2, master, layout), + Target::Handout => (3, 0, 0), + Target::NotesMaster => (4, 0, 0), + }; + let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); + Some(egui::ime_context::ImeTarget::new(owner, 0)) + } + + /// The committed surrounding snapshot for the current edit target, or `Unavailable` when + /// there is an owner but no committed text to describe. + /// + /// An unavailable stamp carries the document revision. The tracker requires a strictly + /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a + /// wide selection or a commit that starts from a selected shape would poison the whole frame. + fn ime_receipt_stamp(&self) -> Option { + let st = self.session.active()?; + self.ime_target()?; + Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( + || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), + |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), + )) + } + + /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any + /// earlier or later IME receipt in the same frame. + fn note_application_edit(&mut self, ctx: &egui::Context) { + let Some(viewport) = self.ime_frame_viewport else { return }; + if let Some(target) = self.ime_target() { + let frame = ctx.cumulative_frame_nr_for(viewport); + self.ime_tracker.note_application_change(viewport, frame, target); + } + } + + /// Record the expected selection after an accepted IME event. Its own deletion may move + /// the caret/revision; that is not an application interruption of the following commit. + fn refresh_ime_terminal_selection(&mut self) { + let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); + if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { + pending.revision = revision; + pending.anchor = anchor; + pending.caret = caret; + } + } + + /// The current live destination for an IME reset fence. + fn ime_destination(&self) -> Option { + let st = self.session.active()?; + let target = self.ime_target()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) + } + + /// Use the recorded IME viewport only while it matches the current context viewport. + fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { + self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) + } + + /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to + /// another viewport's phase. + pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { + self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) + } + + /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. + pub(crate) fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { + if matches!(phase, ImeResetPhase::Clear) { + let _ = self.ime_reset.remove(&viewport); + } else { + self.ime_reset.insert(viewport, phase); + } + } + + /// The live stamp used to compare against the origin/interrupted terminal destination. + fn ime_terminal_stamp(&self) -> Option { + let st = self.session.active()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) + } + + /// Positive route predicate for the canvas IME producer: this pass must be one that can + /// actually paint `canvas::show` for the active document, with no modal, stock field or + /// overlay owning input. + /// + /// Sorter and Notes Page are editor views without a canvas producer, so they are named here + /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or + /// closing window, a missing document/target, a focused stock field, modal dialog, palette, + /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the + /// authority for this pass's actual descriptor; keeping this predicate positive means a view + /// added later is not admitted by omission simply because it does not call `canvas::show`. + pub(crate) fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { + matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) + && self.session.active().is_some() + && self.ime_target().is_some() + && ctx.input(|i| i.viewport().focused.unwrap_or(true)) + && ctx.input(|i| i.viewport().visible() != Some(false)) + && !ctx.input(|i| i.viewport().close_requested()) + && ctx.memory(|m| m.focused().is_none()) + && self.dialog.as_ref().is_none_or(|d| !d.modal()) + && self.palette.is_none() + && self.backstage.is_none() + && self.show.is_none() + && self.opening.is_none() + } + + /// The singleton app's session, composition tracker and terminal guard belong to its owning + /// viewport. View › New Window and Presenter View render through their own immediate + /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only + /// viewport mutate root composition state while only the reset map is per-viewport. Reject it + /// before any state changes instead of pretending to support multi-view editing. + fn root_viewport_admitted(ctx: &egui::Context) -> bool { + ctx.viewport_id() == egui::ViewportId::ROOT + } + + /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than + /// standalone logic. + fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { + let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); + ctx.data(|d| d.get_temp::(key).unwrap_or(false)) + } + + /// A new application-origin interruption supersedes any previous pending fence. + /// + /// This runs from the logic half of a full pass and from raw input handling, before the + /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: + /// putting a token into egui `Memory` now would let egui attach it to a later stock + /// descriptor, or to a canvas this route does not paint. Retire the superseded id + /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may + /// allocate its one replacement after confirming an eligible custom output. + pub(crate) fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { + let viewport = self.ime_viewport_or(ctx); + if !fresh + && matches!( + self.ime_reset_for(viewport), + ImeResetPhase::Awaiting { viewport: guard_viewport, .. } + | ImeResetPhase::Suspended { viewport: guard_viewport, .. } + if guard_viewport == viewport + ) + { + // This viewport already has a live or suspended guard. The finalizer replaces it + // once if the actual destination changed; do not churn a request per event. + return; + } + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + + /// Custom admission was lost on this route. Retire the old id without inventing an + /// acknowledgement and block until a later eligible pass can request a replacement. + fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { + if self.ime_reset_for(viewport).blocks_custom_input() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are + /// inert; this never mutates text, caret or undo history. + fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if request_id.viewport_id() != viewport { + return; + } + if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) + && id == request_id + && waiting_viewport == viewport + { + self.set_ime_reset(viewport, ImeResetPhase::Clear); + // The exact ordered completion is the platform-reset boundary. It also releases a + // cleared-preedit terminal guard: the app no longer has to wait for the old + // Commit/Enabled/Disabled before a fresh composition may start. Events ordered + // before this marker remain rejected; a late old terminal after the reset has the + // same documented residual as any other event crossing a platform reset. + if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { + self.ime_terminal_pending = None; + } + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + } + + /// Controls-only routing for a route where the custom editor cannot mutate. Markers are + /// visited in raw order; stock text/key events stay untouched for the stock widgets. + fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { + for e in events { + if let egui::Event::ImeResetCompleted { request_id } = e { + self.ime_reset_completed(*request_id, ctx); + } + } + } + + /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. + pub(crate) fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { + let viewport = self.ime_viewport_or(ctx); + let phase = self.ime_reset_for(viewport); + let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); + let destination = self.ime_destination(); + // The finalizer is the allocation authority, so it rechecks the positive canvas route + // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, + // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout + // discard or a missing target must suspend without putting a token into egui Memory. + let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); + let custom_output = match (eligible, custom, actual, destination) { + (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), + _ => None, + }; + let Some((output_context, destination)) = custom_output else { + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { + // A request that was not bound to the actual custom output in this pass is + // retired locally; a later eligible activation obtains a fresh one. A guard + // stamped for another viewport (or a request issued for one) is never reused. + if guard_viewport != viewport || request.is_some() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + ImeResetPhase::AllocationFailed { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + return; + }; + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { + let request = if needs_rearm && guard_viewport == viewport { + // Only a request issued for this exact viewport may be bound here. + request.filter(|id| id.viewport_id() == viewport) + } else { + None + }; + match request.or_else(|| ctx.request_ime_reset(viewport)) { + Some(id) => { + self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); + } + None => { + self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); + } + } + } + ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { + // Repeated idle output for the same destination does not retry allocation. + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + } + } + + /// Request one replacement IME reset token and record `Awaiting` on success or + /// `AllocationFailed` when no token can be allocated. + fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { + match ctx.request_ime_reset(viewport) { + Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), + None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), + } + } + + /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying + /// stale input; the next full UI pass can rearm from its actual descriptor. + fn finalize_ime_logic(&mut self, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if self.ime_reset_for(viewport).blocks_custom_input() { + // Only this viewport's guard is touched. A root/child logic tick must not suspend or + // rebind another viewport's independent reset lifetime. + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// Note compositions the application settled since the last check (a click, a key, another + /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], + /// and a platform event for them is stale from that moment on, even in the same pass. + pub(crate) fn note_settled_composition(&mut self, ctx: &egui::Context) { + let settles = self.session.composition_settles(); + let current_stamp = self.ime_terminal_stamp(); + // `file.close` then `file.new` can replace the active uid before any pass observes the + // empty document. That is not a safe boundary for the old terminal: the platform may + // still deliver its suffix. Keep the guard bound to its retired owner and treat the new + // destination as a fresh interruption; only exact reset completion or an observed + // teardown may clear the terminal metadata. + let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { + let reference = if p.interrupted { + p.interrupted_at + } else { + Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) + }; + reference != current_stamp + }); + if settles == self.ime_settles_seen && !pending_interrupted { + return; + } + let fresh = settles != self.ime_settles_seen; + self.ime_settles_seen = settles; + if let Some(pending) = &mut self.ime_terminal_pending { + pending.cancelled = true; + pending.interrupted = true; + // Compare later passes against the destination where interruption actually happened, + // not forever against the retired composition's origin. The destination may be absent + // (the interruption closed its target); `interrupted` keeps that distinct from "not + // interrupted yet". + pending.interrupted_at = current_stamp; + } + self.ime_interrupt_pending = true; + self.note_ime_interruption(ctx, fresh); + let cancelled = self.ime_composition.as_mut().map(|c| { + c.settled = true; + (c.owner, c.generation) + }); + if let Some((owner, generation)) = cancelled { + // Retain a tombstone beyond the first stale event: clearing the composition must + // not let the rest of this input batch commit into the next target. `observe_ime` + // retires it in the pass that publishes the matching interrupt. + self.ime_cancelled = Some(ImeCancelled { owner, generation }); + } + } + + /// An input method's event for the slide's text (#101): the preedit is shown in the text and + /// replaced on each update; the commit replaces it as one undo step. + /// + /// An event only ever changes the target its composition began in. The application settles a + /// composition itself on a click, a key, a command or a target change, and the platform may + /// still hold it: pre-edits and commits queued for that generation in the settling input batch + /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is + /// selected now. After the interrupt has been published the platform is reset, so the next + /// batch is treated as new input; egui's event payload has no generation, so an OS-queued + /// event that crosses that reset cannot be distinguished from a legitimate one. + /// + /// A receipt is captured immediately before and after a successful commit or + /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn + /// [`egui::output::ImeChangeCause::InputMethod`]. + pub(crate) fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { + self.note_settled_composition(ctx); + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + let frame = ctx.cumulative_frame_nr_for(viewport); + let target = self.ime_target(); + let owner = target.map(|t| t.owner); + let accepted = + matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); + if self.reject_stale_ime_event(ctx, e, owner) { + return; + } + // Until the exact newest reset request completes at its ordered stream position, custom + // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do + // not release this guard; they still settle the old composition below. + #[allow(deprecated)] + if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { + return; + } + let before = if accepted { self.ime_receipt_stamp() } else { None }; + let r = self.apply_ime_event(e); + self.settle_ime_event_state(e, owner, r.is_ok()); + if r.is_ok() && accepted { + self.refresh_ime_terminal_selection(); + } + if let Err(e) = &r { + self.set_status(e.to_string()); + } else if accepted + && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) + && before != after + && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) + { + // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is + // not a mutation and must not poison the frame for a real commit later in the batch. + self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); + } + } + + /// Drop an event that belongs to a composition the application already settled, or to another + /// owner; returns `true` when the event must not be applied. + /// + /// A commit or deletion belongs to the composition that began the pre-edit: another owner + /// (the user clicked elsewhere) or a composition the app already settled makes it stale. + /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has + /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass + /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the + /// backend just the same), so a later commit-only is not lost forever: it is a + /// legitimate direct commit or a new platform composition. egui's events carry no + /// generation, so an event queued by the OS across that reset is indistinguishable from + /// one the user just typed; only events in the same input batch as the settle are rejected + /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner + /// remains guarded across that output boundary until its actual commit/enable/disable + /// arrives. Publishing a reset is not evidence that the separately queued terminal was + /// delivered. With no incoming event epoch, the first ambiguous new composition after an + /// unobserved reset may be rejected; no timer guesses that it became safe. + /// The tombstone is not filtered by the *current* target: a click can change the owner in + /// the settling pass, and every remaining event of that batch still belongs to the + /// pre-reset composition. Filtering would let the event after the first dropped pre-edit + /// start composing in the new target before the platform is reset. + fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { + let cancelled = self.ime_cancelled; + let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); + #[allow(deprecated)] + let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) + && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); + if !stale { + return false; + } + if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { + // The old terminal finally arrived: reject it and release its pending owner now, + // so a new preedit later in this same raw batch can belong to the current target. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + return true; + } + if pending_cancelled { + // note_settled_composition already queued the reset. Do not also latch the + // frame's memory interrupt: a terminal later in this batch can consume it and + // permit a fresh preedit without resetting that new composition at pass end. + self.ime_composition = None; + return true; + } + if let Some(c) = cancelled { + log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); + } + self.ime_composition = None; + // Ask the backend to drop its copy, so this is one stale event and not a stream. + ctx.memory_mut(|m| m.interrupt_ime()); + true + } + + /// Apply the document mutation an IME event asks for, before any receipt or tracker state. + fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { + match e { + egui::ImeEvent::Preedit { text, active_range_chars } => { + self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) + } + // A commit only replaces text that is being edited. It must never start editing a + // merely selected shape: without a tracked pre-edit, a stale platform commit would + // replace that shape's whole text. + egui::ImeEvent::Commit(text) => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.commit_text(text) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_chars, *after_chars, false) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_bytes, *after_bytes, true) + } else { + Ok(Value::Null) + } + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // The platform ended (or is about to restart) its composition itself. + self.session.end_composition(); + Ok(Value::Null) + } + } + } + + /// Update the tracked composition after `e` has been applied; `applied` says whether the + /// document mutation succeeded. + fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { + match e { + // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is + // left to interrupt. A pre-edit keeps (or starts) the tracked composition. + egui::ImeEvent::Commit(_) => { + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // A platform-side disable/enable already reset the composition. Do not carry an + // older app-settled interrupt into the next (possibly new) composition. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + egui::ImeEvent::Preedit { text, .. } => { + match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { + (Some(owner), true, true) => { + // Updating the same composition keeps its generation. After a settle (or + // a first pre-edit) this is a new generation and clears the tombstone. + let generation = match self.ime_composition { + Some(c) if c.owner == owner && !c.settled => c.generation, + _ => { + self.ime_generation = self.ime_generation.wrapping_add(1).max(1); + self.ime_generation + } + }; + self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); + self.ime_terminal_pending = None; + self.ime_cancelled = None; + } + (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { + // The model restored committed text, but no terminal was delivered. Keep + // this owner across passes; a later pointer/key/target change must reject + // its separately queued old delete/commit, even after an outgoing reset. + if let Some(st) = self.session.active() + && let Some(t) = &st.selection.text + { + self.ime_terminal_pending = Some(ImeTerminalPending { + owner, + revision: st.revision, + anchor: t.anchor, + caret: t.caret, + cancelled: false, + interrupted: false, + interrupted_at: None, + }); + } + } + _ => { + self.ime_composition = None; + self.ime_terminal_pending = None; + } + } + } + _ => {} + } + } + + /// Observe the canvas IME owner once per pass and publish its descriptor. + /// + /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, + /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means + /// the custom owner is hidden: the tracker is told so and the next focus starts a new + /// generation. The descriptor is rebuilt from the current committed text every pass. + /// + /// A composition the application settled itself is announced once as + /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That + /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit + /// terminal retains its separate pending owner until the incoming terminal is observed. + pub(crate) fn observe_ime(&mut self, ctx: &egui::Context) -> Option { + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + self.note_settled_composition(ctx); + // The editing window going away really ends the viewport's context; a merely hidden + // owner (modal, palette, unfocused field) stays observed as unfocused instead. + if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { + self.ime_interrupt_pending = false; + if let Some(old) = self.ime_viewport.take() { + self.ime_tracker.end_viewport(old); + // Explicit viewport destruction removes only that viewport's guard. + let _ = self.ime_reset.remove(&old); + } + let _ = self.ime_reset.remove(&viewport); + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_terminal_pending = None; + return None; + } + if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { + // The native window itself lost focus: no custom editor is eligible this pass, even + // if the model still has a text selection and a last-pass geometry rect. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(rect) = self.ime_pending.take() else { + // No canvas editor was painted in this pass. A discarded font/layout pass publishes + // nothing the platform sees, so keep the interrupt and tombstone for a later pass. + // An accepted pass is a backend reset only when it publishes *no* IME descriptor at + // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its + // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate + // retirement on the actual committed output, not on the absence of the canvas rect; + // otherwise the next legitimate commit in the focused field would be rejected. + let no_descriptor = ctx.output(|o| o.ime.is_none()); + if !ctx.will_discard() && no_descriptor { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + self.ime_tracker.observe(viewport, None); + return None; + }; + if !self.ime_custom_canvas_route(ctx) { + // The canvas can still paint behind an opening overlay or a Sorter/Notes Page + // reroute, and a forced hidden paint can run after the window lost focus or + // visibility. The positive route and the UI wrapper both reject those, so a pending + // rect must not republish the custom owner. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(st) = self.session.active() else { + self.ime_interrupt_pending = false; + self.ime_tracker.observe(viewport, None); + return None; + }; + let Some(target) = self.ime_target() else { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + self.ime_tracker.observe(viewport, None); + return None; + }; + if self.ime_viewport != Some(viewport) + && let Some(old) = self.ime_viewport.replace(viewport) + { + self.ime_tracker.end_viewport(old); + let _ = self.ime_reset.remove(&old); + } + let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); + let mut observation = + egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) + .purpose(egui::IMEPurpose::Normal) + .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); + if committed.is_none() { + observation = observation.unavailable_revision(st.revision); + } + let update = self.ime_tracker.observe(viewport, Some(observation))?; + // Clear the interrupt only once it is part of a settled pass's output descriptor. A + // discarded font/layout pass publishes nothing the platform sees, so keep it pending; + // that pass is not the boundary at which stale pre-edits become a new generation. + let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; + if interrupt { + // This pass's output carries the backend reset. The tombstone did its job for the + // events already processed in this batch; from the next batch on every pre-edit or + // commit is a new platform generation. A late event the OS queued across the reset + // has no generation in its payload, so it cannot be rejected without also dropping + // legitimate direct commits (documented residual in `ime_event`). + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect: rect, + should_interrupt_composition: interrupt, + reset_request: None, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + Some(key) + } + + /// Forget the injected or queued synthetic batch and everything recorded about it. Events + /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. + fn clear_synthetic_batch(&mut self) { + self.synthetic.clear(); + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_target = None; + self.synthetic_focus = None; + } + + /// Start the batch that was queued behind an older one once the older batch is gone. + fn promote_next_synthetic(&mut self) { + if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { + return; + } + self.synthetic = std::mem::take(&mut self.synthetic_next); + self.synthetic_target = self.synthetic_next_target.take(); + self.synthetic_focus = None; + } + + /// A font/layout pass will be discarded: egui already consumed the injected events but the + /// app deliberately lays nothing out, so put them back at the head of the queue. The next + /// frame injects them again after the new fonts are installed; otherwise a control click that + /// happened to arrive on the very first frame would be acknowledged without ever reaching the + /// UI. + fn requeue_injected_synthetic(&mut self) { + if !self.synthetic_pending { + return; + } + let mut combined = std::mem::take(&mut self.synthetic_injected); + combined.append(&mut self.synthetic); + self.synthetic = combined; + self.synthetic_pending = false; + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + self.promote_next_synthetic(); + } + } + + /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried + /// first so an identical host event earlier in the batch is never removed; the last exact + /// match is only a fallback for a raw input that was rewritten around the events. + fn drain_synthetic_from(&self, events: &mut Vec) -> bool { + let injected = &self.synthetic_injected; + let n = injected.len(); + if n == 0 { + return false; + } + let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); + let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); + if let Some(at) = at { + events.drain(at..at + n); + true + } else { + false + } + } + + /// Drop an injected batch whose document, canvas, focus or modal owner changed during + /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained + /// there can still retarget the same `RawInput`. + /// + /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run + /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or + /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* + /// pass; remove the batch only when the live input carries exactly what this pass injected + /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending + /// bookkeeping so the next `raw_input_hook` can retract the events from the carried + /// `RawInput` before eframe delivers them in a full pass. + fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { + if !self.synthetic_pending { + return; + } + let current = self.input_target(); + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_target == Some(current) && focus == self.synthetic_focus { + return; + } + let injected_time = self.synthetic_injected_time; + let mut removed = false; + ctx.input_mut(|i| { + // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the + // time of the RawInput this batch was injected into. Never drain an older batch that + // happens to look similar. + if i.raw.time != injected_time { + return; + } + let mut events = std::mem::take(&mut i.events); + let ok = self.drain_synthetic_from(&mut events); + i.events = events; + if ok { + // Keep `InputState::raw` consistent with what UI code reads as `events`. + let mut raw_events = std::mem::take(&mut i.raw.events); + let _ = self.drain_synthetic_from(&mut raw_events); + i.raw.events = raw_events; + removed = true; + } + }); + if removed { + self.clear_synthetic_batch(); + self.promote_next_synthetic(); + } else { + // Logic-only (or a pass whose input does not contain the batch): the events are in + // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the + // stale target so the next hook retracts them there. Events queued after the batch + // belong to the same stale target as well. + self.synthetic.clear(); + } + } + + /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is + /// the full pass the batch was admitted for and egui has already derived pointer/key state + /// from it. A logic-only callback leaves the previous pass's `InputState` (with another + /// `raw.time`) and must not be mistaken for the live pass, so control/background work is + /// still drained there and the next `raw_input_hook` retracts a batch that went stale. + fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { + if !self.synthetic_pending || self.synthetic_injected.is_empty() { + return false; + } + let Some(injected_time) = self.synthetic_injected_time else { return false }; + let injected = &self.synthetic_injected; + ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) + } + + /// Input that belongs to the pass about to run: native dropped files and one synthetic + /// control-channel batch. The synthetic batch is injected at most once; while it is + /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change + /// drops the whole queued batch rather than typing into the wrong place. + /// + /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will + /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe + /// carries the whole `RawInput` into the next full pass, so an already-injected batch is + /// retracted at the next hook if a control request changed the target, and otherwise is + /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase + /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + #[cfg(not(target_arch = "wasm32"))] + for f in std::mem::take(&mut raw.dropped_files) { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); + match f.bytes() { + Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), + Err(e) => self.set_status(e), + } + } + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_pending { + let current = self.input_target(); + if self.synthetic_target != Some(current) || focus != self.synthetic_focus { + // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` + // and appends the next host input to it. The events are already in `raw.events`; + // clearing only the unsent queue would still deliver the stale click or key into + // the new document on the next full pass. Retract exactly the batch we appended. + let _ = self.drain_synthetic_from(&mut raw.events); + self.clear_synthetic_batch(); + // New control input queued for the current target while the stale batch was in + // flight is a separate batch; keep it instead of clearing it with the old one. + self.promote_next_synthetic(); + } else { + // This RawInput is the carrier for the next pass, and eframe refreshes its time + // on every call. Record the current time so `retract_synthetic_after_logic` can + // tell this full pass's live `InputState` from a logic-only callback's stale one. + self.synthetic_injected_time = raw.time; + } + } + if self.synthetic_pending || self.synthetic.is_empty() { + return; + } + let current = self.input_target(); + if self.synthetic_target != Some(current) { + // The batch was queued for a document/slide/canvas that has since changed. + self.synthetic.clear(); + self.synthetic_target = None; + self.promote_next_synthetic(); + if self.synthetic.is_empty() { + return; + } + if self.synthetic_target != Some(current) { + // The newer batch is stale too: drop it rather than delivering it to this target. + self.synthetic.clear(); + self.synthetic_target = None; + self.synthetic_next.clear(); + self.synthetic_next_target = None; + return; + } + } + self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); + log::debug!("injecting synthetic input batch {}", self.synthetic_generation); + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let n = n.min(self.synthetic.len()); + let batch: Vec = self.synthetic.drain(..n).collect(); + let at = raw.events.len(); + raw.events.extend(batch.iter().cloned()); + self.synthetic_injected = batch; + self.synthetic_injected_at = Some(at); + self.synthetic_injected_time = raw.time; + self.synthetic_pending = true; + self.synthetic_focus = focus; + } + + /// The app shortcuts that still make sense on the empty File/start screen: create, open, + /// the command palette, preferences and the File tab itself. Document commands (save, find, + /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. + fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { + use egui::Key::*; + matches!( + (key, m.cmd, m.shift, m.alt), + (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) + ) + } + + /// File-tab navigation and document-free shortcuts, also used on the start screen. + fn route_backstage_keys(&mut self, events: Vec) { + if self.backstage.is_none() { + return; + } + // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app + // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the + // hidden presentation. + let start_screen = self.session.active().is_none(); + for e in events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + let handled = backstage::key(self, key, mods) + // With no presentation the File home only owns the shortcuts that create or + // open one. Borrowing the whole document shortcut table here would also arm + // Save/Save As, hyperlink, find/replace, show and zoom against the empty + // start screen, which is a scope expansion no editing route needs. + || (mods.cmd || mods.alt) + && (!start_screen || Self::start_screen_shortcut(key, mods)) + && menus::ui_shortcut(self, key, mods); + if handled { + continue; + } + // Engine shortcuts that don't touch the hidden slide (Ctrl+N). + if mods.cmd + && matches!(key, egui::Key::N) + && let Err(e) = self.session.key(key_name(key), mods) + { + self.set_status(e.to_string()); + } + } + } + } + + /// Keyboard and clipboard for the slide (when no text field has focus). + fn keyboard(&mut self, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the + // key press, so the release of a V press never seen is the paste. Tracked even while a text + // field has focus, so that a V typed there is not taken for one. + let mut image_paste = false; + let mut pasted_image_event = false; + for e in &events { + match e { + egui::Event::PasteImage(_) => { + pasted_image_event = true; + self.v_seen = true; + } + egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, + egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { + // Only a release with Command/Ctrl held is a paste: a bare V release is not. + let seen = std::mem::take(&mut self.v_seen); + image_paste = !seen && (modifiers.command || modifiers.ctrl); + } + _ => {} + } + } + let viewport = self.ime_viewport_or(ctx); + if self.session.active().is_none() { + // No document: keep slide-editing input suspended, while the start screen's + // File-tab navigation and document-free shortcuts remain available. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { + self.route_backstage_keys(events); + } + self.drain_requests(); + return; + } + let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); + // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow + // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon + // buttons), and that stray focus would swallow every later key and end text entry. Let it + // go; a real text field or an open menu keeps its keys. + if !modal + && self.session.active().is_some_and(|d| d.selection.text.is_some()) + && !ctx.text_edit_focused() + && !ctx.any_popup_open() + && let Some(id) = ctx.memory(|m| m.focused()) + { + ctx.memory_mut(|m| m.surrender_focus(id)); + } + if ctx.memory(|m| m.focused().is_some()) || modal { + // A stock field/modal/palette owns input now. Retire the custom fence, then visit + // controls in raw order; stock text/key events are left for the stock widgets. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + return; + } + if self.backstage.is_some() { + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + self.route_backstage_keys(events); + self.drain_requests(); + return; + } + let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + if let Some(png) = img { + let data = deckcraft_engine::cmd::base64_encode(&png); + if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { + self.set_status(e.to_string()); + } + } + for e in events { + // An earlier command in this raw batch may have settled the composition. Request the + // replacement at this ordered boundary, before the current event is considered. + self.note_settled_composition(ctx); + match e { + egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), + egui::Event::PasteImage(image) => match color_image_png(&image) { + Ok(png) => { + let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); + if let Err(e) = self.session.execute("insert.picture", ¶ms) { + self.set_status(e.to_string()); + } + } + Err(e) => self.set_status(e), + }, + egui::Event::Text(t) => { + let revision = self.session.active().map(|d| d.revision); + if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) + && let Err(e) = self.session.type_text(&t) + { + self.set_status(e.to_string()); + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + egui::Event::Ime(ime) => self.ime_event(ctx, &ime), + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), + egui::Event::Paste(text) => { + // Our own marker for objects without text: paste what the session copied. + let text = if text == OBJECTS_MARKER { String::new() } else { text }; + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); + // egui 0.37 sends `PasteImage` directly; the services fallback stays for + // integrations that still send an empty `Paste`. Never insert twice. + let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + let revision = self.session.active().map(|d| d.revision); + let r = match img { + Some(png) => self + .session + .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Tab { + // The slide has the keyboard, so Tab is ours (indent, tab character, next + // shape). Left to egui it would also focus the first ribbon widget, and + // every key after it would go there instead of the slide (#78). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { + self.anim_preview = None; + continue; + } + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { + continue; + } + let name = key_name(key); + let revision = self.session.active().map(|d| d.revision); + match self.session.key(name, mods) { + Ok(_) => {} + Err(e) => self.set_status(e.to_string()), + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + _ => {} + } + } + self.drain_requests(); + } + + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). + fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { + return; + } + ctx.copy_text(system_clipboard_text(&text)); + } + + /// Lay out the whole window. + /// + /// Every return route, including fonts/layout discards, show mode, modal/Backstage and the + /// no-document paths, reaches the same outer reset observation and finalizer below. No early + /// route may publish or inspect IME output on its own. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !Self::root_viewport_admitted(&ctx) { + log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id()); + return; + } + self.ui_inner(ui); + // The last writer (all panels, dialogs, the custom producer and deferred child windows) + // has run. Observe the actual custom descriptor for this pass, then reconcile the reset + // lifetime against the actual committed output. This also runs for discarded passes. + self.ime_frame_viewport = Some(ctx.viewport_id()); + let custom = self.observe_ime(&ctx); + self.finalize_ime_reset(&ctx, custom); + } + + /// Run the admitted root UI pass, including setup gating, panels, canvas, dialogs, overlays, and + /// show mode. + fn ui_inner(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.ime_frame_viewport = Some(ctx.viewport_id()); + self.ime_pending = None; + if !self.ui_setup(&ctx) { + // This pass is discarded for the new fonts. The input events were already consumed + // by egui, so keep them queued for the next frame instead of silently losing them. + self.requeue_injected_synthetic(); + return; + } + let t0 = now_ms(); + let t = theme::Tokens::get(&ctx); + self.note_ui_text(&ctx); + self.media.sync_video_textures(&ctx); + canvas::trackpad_twist(self, &ctx); + eyedropper::tick(self, &ctx); + self.opening_ui(&ctx); + if self.show.is_some() { + self.anim_preview = None; + show::ui(self, ui); + doc_window::show(self, &ctx); + self.perf.frame_ms = now_ms() - t0; + self.ui_end(&ctx); + return; + } + self.sync_backstage(); + self.keyboard(&ctx); + self.sync_backstage(); + ribbon::title_bar(self, ui); + if self.backstage.is_none() && self.session.active().is_some() { + ribbon::show(self, ui); + status::status_bar(self, ui); + } + let now = ui.input(|i| i.time); + if self.tick_anim_preview(now) { + ctx.request_repaint(); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + return; + } + if self.session.active().is_none() { + return; + } + match self.ui.view { + ViewMode::Sorter => sorter::show(self, ui), + ViewMode::NotesPage => sorter::notes_page(self, ui), + ViewMode::Outline | ViewMode::Normal | ViewMode::Reading => { + if self.ui.pane.is_some() { + panes::show(self, ui); + } + if self.ui.view == ViewMode::Outline { + sorter::outline_pane(self, ui); + } else { + thumbs::show(self, ui); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + if self.ui.notes { + status::notes_pane(self, ui); + } + canvas::show(self, ui); + }); + } + } + }); + toast::note_missing_fonts(self); + toast::show(self, &ctx); + dialogs::show(self, &ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, &ctx); + menus::palette(self, &ctx); + for url in std::mem::take(&mut self.pending_urls) { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + if let Some(t) = self.pending_copy.take() { + ctx.copy_text(t); + } + self.drain_requests(); + doc_window::show(self, &ctx); + self.perf.frame_ms = now_ms() - t0; + self.ui_end(&ctx); + } + + /// Media clocks. Voices belong to the open presentation: closing the last one, or switching + /// decks, stops them. The editor's media also stops when its slide is left. This runs from + /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. + fn tick_media_clock(&mut self, ctx: &egui::Context) { + let uid = self.session.active().map(|d| d.uid); + self.media.set_document(uid); + if !self.media.any_active() { + self.session.media_status.clear(); + return; + } + if self.show.is_none() + && let Some(d) = self.session.active() + { + let here: Vec = if d.selection.target == deckcraft_engine::Target::Slides { + media::slide_media(&d.doc, d.selection.slide).into_iter().map(|m| m.0).collect() + } else { + vec![] + }; + let gone: Vec<_> = + self.media.ids().into_iter().filter(|id| !here.contains(id) && self.media.owner(*id) == Some(media::Owner::Editor)).collect(); + for id in gone { + self.media.stop(id); + } + } + let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); + if playing { + ctx.request_repaint_after(std::time::Duration::from_millis(15)); + } + } + + pub fn open_url(&mut self, url: &str) { + self.pending_urls.push(url.to_string()); + } + + /// Drain pending control requests, executing ordinary requests and admitting screenshots for + /// later saving and reply. + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + // A request whose sender gave up waiting (reply timeout) must not run late (#719). + if !req.take() { + continue; + } + let reply = req.reply.clone(); + let revision = self.session.active().map(|d| d.revision); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + let doc = self.session.active().map(|d| d.uid); + if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { + let _ = reply.send(json!({"ok": false, "error": e})); + } + } + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + self.control_rx = Some(rx); + } + + /// Save completed screenshots on the app thread and send exactly one reply each. + fn drain_screenshots(&mut self) { + let doc = self.session.active().map(|d| d.uid); + let completed = self.capture.drain(web_time::Instant::now(), doc); + for c in completed { + let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); + // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. + self.capture.release(c.reserved); + let _ = c.reply.send(v); + } + } +} + +/// Engine key name for an egui key. +pub fn key_name(key: egui::Key) -> &'static str { + use egui::Key::*; + match key { + Enter => "Enter", + Escape => "Escape", + Backspace => "Backspace", + Delete => "Delete", + Tab => "Tab", + ArrowLeft => "Left", + ArrowRight => "Right", + ArrowUp => "Up", + ArrowDown => "Down", + Home => "Home", + End => "End", + PageUp => "PageUp", + PageDown => "PageDown", + Space => "Space", + F2 => "F2", + F5 => "F5", + F7 => "F7", + other => other.name(), + } +} + +/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as +/// background removal. The image was already allocated by the integration; this keeps the +/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. +const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; + +/// Encode an egui color image as the PNG bytes `insert.picture` stores. +fn color_image_png(image: &egui::ColorImage) -> Result, String> { + let [w, h] = image.size; + let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; + if pixels == 0 { + return Err("the pasted image is empty".into()); + } + if pixels > MAX_PASTE_IMAGE_PIXELS { + return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); + } + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) +} + +/// Images for the browser download: the current slide, or every slide (named `1.png`, +/// `2.png`... like the native export) when `all`. `transparent` leaves the slide +/// background out (PNG only, #784). +pub(crate) fn web_image_files( + doc: &deckcraft_model::Presentation, + current: usize, + all: bool, + name: &str, + ext: &str, + transparent: bool, +) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + let scale = 2.0; + let dpi = deckcraft_render::dpi_for_scale(scale); + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; + let img = deckcraft_render::render_slide(doc, i, &opts); + let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 000000000..872fe7e42 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,16 @@ +/// Seconds since this process's monotonic origin. +/// +/// `web_time::Instant` is `std::time::Instant` natively and `performance.now()` on wasm; it never +/// jumps backwards and is not `Date.now()` (which is wall time and may be zero/frozen in a +/// browser). Use this for deadlines, show/media clocks and duration displays. +static CLOCK_ORIGIN: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Monotonic seconds since the first call. +pub fn monotonic_secs() -> f64 { + CLOCK_ORIGIN.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() +} + +/// Monotonic milliseconds. Kept for status/toast durations and frame timings; never wall time. +pub fn now_ms() -> f64 { + monotonic_secs() * 1000.0 +} diff --git a/crates/ui-egui/src/ime_hooks.rs b/crates/ui-egui/src/ime_hooks.rs new file mode 100644 index 000000000..93f3f8513 --- /dev/null +++ b/crates/ui-egui/src/ime_hooks.rs @@ -0,0 +1,39 @@ +/// Temp key holding whether the current viewport is inside an egui UI pass. +pub(crate) fn ime_in_ui_pass_key(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique(("deckcraft-ime-in-ui-pass", viewport)) +} + +/// Stable key marking that the IME pass hooks are installed. +fn ime_pass_hooks_key() -> egui::Id { + egui::Id::unique(("deckcraft-ime-pass-hooks", "installed")) +} + +/// Install the per-viewport UI-pass mode hooks exactly once for this [`egui::Context`]. +/// +/// Call this before the context's first `run_ui` (desktop/web shells and the test harness). +/// The callbacks receive `&mut Ui`; they obtain the context through `ui.ctx()` and capture no +/// context or app reference, so they cannot form a reference cycle. The flag distinguishes a +/// full `run_ui` pass (where `App::logic` runs before the UI that owns the final output) from a +/// standalone `run_logic` callback. It is only a mode marker, not an IME publication receipt. +pub fn install_ime_pass_hooks(ctx: &egui::Context) { + if ctx.data(|d| d.get_temp::(ime_pass_hooks_key()).is_some_and(|v| v)) { + return; + } + ctx.on_begin_pass( + "deckcraft-ime-ui-pass-begin", + std::sync::Arc::new(|ui: &mut egui::Ui| { + let ctx = ui.ctx(); + let key = ime_in_ui_pass_key(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(key, true)); + }), + ); + ctx.on_end_pass( + "deckcraft-ime-ui-pass-end", + std::sync::Arc::new(|ui: &mut egui::Ui| { + let ctx = ui.ctx(); + let key = ime_in_ui_pass_key(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(key, false)); + }), + ); + ctx.data_mut(|d| d.insert_temp(ime_pass_hooks_key(), true)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 060952bc4..2ec615902 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,106 +50,20 @@ use serde_json::{Value, json}; pub use control::{ControlRequest, ControlResponse}; -pub type ReadFn = Box Result, String>>; -pub type WriteFn = Box Result<(), String>>; -pub type PickFn = Box Option>; -/// `(purpose, request id)`. The host shows a picker and later pushes an [`InboxEvent`] -/// carrying that same id. Completing a cancelled or unknown id does nothing: the file is -/// not applied to whichever document happens to be active. -pub type OpenAsyncFn = Box; -pub type DownloadFn = Box; +mod app; +mod clock; +mod ime_hooks; +mod services; +mod state; -/// Temp key holding whether the current viewport is inside an egui UI pass. -fn ime_in_ui_pass_key(viewport: egui::ViewportId) -> egui::Id { - egui::Id::unique(("deckcraft-ime-in-ui-pass", viewport)) -} - -/// Stable key marking that the IME pass hooks are installed. -fn ime_pass_hooks_key() -> egui::Id { - egui::Id::unique(("deckcraft-ime-pass-hooks", "installed")) -} - -/// Install the per-viewport UI-pass mode hooks exactly once for this [`egui::Context`]. -/// -/// Call this before the context's first `run_ui` (desktop/web shells and the test harness). -/// The callbacks receive `&mut Ui`; they obtain the context through `ui.ctx()` and capture no -/// context or app reference, so they cannot form a reference cycle. The flag distinguishes a -/// full `run_ui` pass (where `App::logic` runs before the UI that owns the final output) from a -/// standalone `run_logic` callback. It is only a mode marker, not an IME publication receipt. -pub fn install_ime_pass_hooks(ctx: &egui::Context) { - if ctx.data(|d| d.get_temp::(ime_pass_hooks_key()).is_some_and(|v| v)) { - return; - } - ctx.on_begin_pass( - "deckcraft-ime-ui-pass-begin", - std::sync::Arc::new(|ui: &mut egui::Ui| { - let ctx = ui.ctx(); - let key = ime_in_ui_pass_key(ctx.viewport_id()); - ctx.data_mut(|d| d.insert_temp(key, true)); - }), - ); - ctx.on_end_pass( - "deckcraft-ime-ui-pass-end", - std::sync::Arc::new(|ui: &mut egui::Ui| { - let ctx = ui.ctx(); - let key = ime_in_ui_pass_key(ctx.viewport_id()); - ctx.data_mut(|d| d.insert_temp(key, false)); - }), - ); - ctx.data_mut(|d| d.insert_temp(ime_pass_hooks_key(), true)); -} - -/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. -#[derive(Clone, Debug)] -pub struct InboxFile { - /// Cancellation identity. A file whose id was cancelled, or never started, is ignored. - pub id: u64, - pub name: String, - pub bytes: Vec, -} - -/// What the host delivers through [`Inbox`] after an asynchronous read. -#[derive(Clone, Debug)] -pub enum InboxEvent { - File(InboxFile), - /// The picker was dismissed or the file could not be read. `id` will not be applied. - Cancelled { - id: u64, - }, -} - -/// Files delivered asynchronously by the host (web file picker, dropped files). -pub type Inbox = std::sync::Arc>>; - -/// The operation the user asked for. Kept with the request so a late completion does not -/// guess a command from the file name. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FilePurpose { - Open, - Picture, - Audio, - Video, - /// Change Picture: the picture replaces the selected one (`picture.change`). - ChangePicture, - /// A drop, or any other purpose: decide from the name and bytes. - Infer, -} - -/// Where in a presentation a file should land. Ids, not indices: slides can move while -/// the browser is still reading. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FilePlace { - Slide(SlideId), - Master(MasterId), - Layout { - master: MasterId, - layout: LayoutId, - }, - /// The handout or notes master (one per presentation, so no id is needed). - NotesPage(Target), - /// The document was open but had no current slide, master or layout. - Unspecified, -} +pub use app::key_name; +#[cfg(test)] +use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; +pub use clock::{monotonic_secs, now_ms}; +pub use ime_hooks::install_ime_pass_hooks; +pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; +use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp}; +pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE}; /// The presentation (and place in it) captured when the request started. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -169,240 +83,6 @@ struct PendingFile { target: FileTarget, } -struct LocatedPlace { - target: Target, - slide: Option, -} - -fn purpose_of(purpose: &str) -> FilePurpose { - match purpose { - "open" => FilePurpose::Open, - "picture" => FilePurpose::Picture, - "audio" => FilePurpose::Audio, - "video" => FilePurpose::Video, - _ => FilePurpose::Infer, - } -} - -fn is_presentation_name(lower: &str) -> bool { - lower.ends_with(".deckcraft") - || lower.ends_with(".slidecraft") - || lower.ends_with(".pptx") - || lower.ends_with(".potx") - || lower.ends_with(".ppsx") -} - -/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. -fn open_command(name: &str) -> Option<&'static str> { - let lower = name.to_ascii_lowercase(); - if is_presentation_name(&lower) { - Some("file.openBytes") - } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { - Some("slide.fromOutline") - } else { - None - } -} - -/// A drop has no requested operation, so the name and bytes decide: a presentation opens, -/// then a recognised picture, sound or video is inserted, then an outline becomes slides. -fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { - let lower = name.to_ascii_lowercase(); - if is_presentation_name(&lower) { - return Some("file.openBytes"); - } - let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); - if ct.starts_with("image/") { - Some("insert.picture") - } else if ct.starts_with("audio/") { - Some("insert.audio") - } else if ct.starts_with("video/") { - Some("insert.video") - } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { - Some("slide.fromOutline") - } else { - None - } -} - -fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { - let cmd = match purpose { - FilePurpose::Picture => Some("insert.picture"), - FilePurpose::Audio => Some("insert.audio"), - FilePurpose::Video => Some("insert.video"), - FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), - FilePurpose::Open => open_command(name), - FilePurpose::Infer => infer_command(name, bytes), - }; - cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) -} - -/// How long a transient status message stays visible. -const STATUS_LINGER_MS: f64 = 6000.0; - -/// Platform services injected by the host (desktop or web). -#[derive(Default)] -pub struct Services { - /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. - pub pick_open: Option, - /// Save dialog with a suggested name → path. - pub pick_save: Option, - pub read: Option, - pub write: Option, - /// Asynchronous open dialog (web). The callback receives the purpose and the request id - /// from [`SlideApp::begin_external_file`]; the chosen file arrives through `inbox`. - pub open_async: Option, - /// Hand bytes to the user as a named file (browser download). - pub download: Option, - pub inbox: Option, - /// Image on the system clipboard (PNG bytes), when the host can read one. - pub clipboard_image: Option Option>>>, - /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`). - pub clipboard_html: Option Option>>, - /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. - pub clipboard_set_html: Option bool>>, - /// Audio output for media playback (cpal on desktop); without one media plays silently. - pub audio_out: Option>, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ViewMode { - #[default] - Normal, - Outline, - Sorter, - NotesPage, - Reading, -} - -/// Persisted UI state. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct UiState { - pub brightness: theme::Brightness, - pub tab: String, - pub ribbon_collapsed: bool, - pub view: ViewMode, - pub notes: bool, - pub notes_height: f32, - pub thumbs_width: f32, - /// Vertical scroll offset of the thumbnail pane, recorded each frame for the control - /// channel and tests; not persisted. - #[serde(skip)] - pub thumbs_scroll: f32, - /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`, - /// `smartart` (Text Pane). - pub pane: Option, - pub pane_width: f32, - pub ruler: bool, - pub gridlines: bool, - /// Slides per page previewed in Handout Master view (1, 2, 3, 4, 6 or 9). - pub handout_per_page: u8, - pub guides: bool, - pub smart_guides: bool, - /// None = fit to window. - pub zoom: Option, - pub sorter_zoom: f32, - pub recent: Vec, - pub presenter_view: bool, - pub ui_scale: f32, - /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). - pub format_tab: String, - /// The Text Pane's current SmartArt line (Add Shape, Promote and Demote act on it). - pub smartart_row: usize, - /// A Text Pane line to focus once it is drawn (after Enter, Tab or Backspace rebuilt the list). - pub smartart_focus: Option, - /// The Text Pane's line drafts, for the SmartArt group they were read from. - pub smartart_drafts: (u32, Vec), - pub grayscale: bool, - /// Comment the Review Previous/Next buttons last landed on: `(slide, index)`. - #[serde(skip_serializing_if = "Option::is_none")] - pub comment_focus: Option<(usize, usize)>, - /// Quick Access Toolbar buttons, left to right. - pub qat: Vec, -} - -impl Default for UiState { - fn default() -> Self { - UiState { - brightness: theme::Brightness::Light, - tab: "Home".into(), - ribbon_collapsed: false, - view: ViewMode::Normal, - notes: true, - notes_height: 52.0, - thumbs_width: 236.0, - thumbs_scroll: 0.0, - pane: None, - pane_width: 300.0, - ruler: false, - gridlines: false, - handout_per_page: 6, - guides: false, - smart_guides: true, - zoom: None, - sorter_zoom: 1.0, - recent: vec![], - presenter_view: true, - ui_scale: 1.0, - format_tab: "fill".into(), - smartart_row: 0, - smartart_focus: None, - smartart_drafts: (0, vec![]), - grayscale: false, - comment_focus: None, - qat: qat::defaults(), - } - } -} - -/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) -/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the -/// shortcut never reached the session and only the context menu could paste objects (#182). An -/// invisible word joiner, so a paste into another app adds nothing visible. -const OBJECTS_MARKER: &str = "\u{2060}"; - -/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. -fn system_clipboard_text(t: &str) -> String { - if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } -} - -/// The editor's zoom range (1 = 100 %). -pub const ZOOM_RANGE: std::ops::RangeInclusive = 0.1..=4.0; - -impl UiState { - /// The state with every value usable: `ui.json` is a file on disk, and a damaged or - /// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the - /// renderer for a texture larger than memory at start-up; panel sizes, scales and names are - /// brought into range the same way, and the recent list is cut to its usual length. - pub fn sanitized(mut self) -> UiState { - let d = UiState::default(); - let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default }; - self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end())); - self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom); - self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale); - self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height); - self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width); - self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width); - let short = |s: &String| s.len() <= 64; - if !short(&self.tab) { - self.tab = d.tab; - } - if !short(&self.format_tab) { - self.format_tab = d.format_tab; - } - self.pane = self.pane.filter(short); - self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); - self.recent.dedup(); - self.recent.truncate(RECENT_MAX); - self - } -} - -/// How many recent files File › Open and Home list. -pub const RECENT_MAX: usize = 12; - /// UI-only commands (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.normal", "Normal", None, "{}"), @@ -717,45 +397,6 @@ struct ImeTerminalPending { interrupted_at: Option, } -/// The live target/selection stamp used to detect a new application interruption without -/// discarding the retired composition's origin. Owner is optional because an interruption can -/// remove the canvas target entirely. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ImeTerminalStamp { - owner: Option, - revision: u64, - anchor: Option<(usize, usize)>, - caret: Option<(usize, usize)>, -} - -/// The live canvas target and caret/selection stamp a reset fence is bound to. -/// -/// The retired composition's origin is deliberately absent: an app-originated interruption can -/// move the destination, and the replacement request belongs to the current destination. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ImeDestination { - owner: egui::Id, - revision: u64, - anchor: Option<(usize, usize)>, - caret: Option<(usize, usize)>, -} - -/// The actual custom descriptor identity a reset request is allowed to acknowledge. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ImeDescriptorKey { - owner: egui::Id, - context_generation: u64, - purpose: egui::IMEPurpose, -} - -impl ImeDescriptorKey { - /// Construct the exact custom-IME descriptor identity from owner, context generation, and - /// purpose. - fn new(owner: egui::Id, context_generation: u64, purpose: egui::IMEPurpose) -> Self { - Self { owner, context_generation, purpose } - } -} - /// Explicit lifetime of the canvas's token-bound IME reset fence. /// /// This is separate from the composition provenance/tombstone state above. Only a matching @@ -782,2774 +423,6 @@ impl ImeResetPhase { } } -fn grid_options_touched(p: &Value) -> bool { - ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) -} - -fn grid_flag(p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(format!("`{key}` must be true or false")), - } -} - -impl SlideApp { - /// Build application UI state, taking ownership of audio output and initialising media, dialogs, - /// synthetic input, and timing fields. - pub fn new(session: Session, mut services: Services) -> Self { - let audio_out = services.audio_out.take(); - SlideApp { - last_fill: serde_json::json!("accent1"), - last_outline: serde_json::json!("tx1"), - table_pen: serde_json::json!({"width": 1.0}), - session, - services, - ui: UiState::default(), - textures: textures::Textures::default(), - control_rx: None, - squiggles: Default::default(), - dialog: None, - thesaurus_query: String::new(), - show: None, - anim_preview: None, - reuse_target: None, - media: media::MediaHost::new(audio_out), - status: None, - opening: None, - toast: None, - fonts_noted: Default::default(), - perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, - synthetic: vec![], - synthetic_pending: false, - synthetic_next: vec![], - synthetic_next_target: None, - synthetic_injected: vec![], - synthetic_injected_at: None, - synthetic_generation: 0, - synthetic_target: None, - synthetic_focus: None, - synthetic_injected_time: None, - ribbon_scroll: HashMap::new(), - show_instance: 0, - opening_generation: 0, - canvas_rect: None, - eyedropper: None, - canvas_scale: 1.0, - slide_rect: None, - canvas_region: None, - palette: None, - integrated_titlebar: false, - backstage: None, - pending_urls: vec![], - #[cfg(not(target_arch = "wasm32"))] - updates_rt: updates_ui::Runtime::default(), - pending_copy: None, - capture: capture::Capture::default(), - styled: false, - restyle: true, - fonts_ready: false, - last_time: 0.0, - last_recovery: 0.0, - quit_confirmed: false, - notes_buf: (0, usize::MAX, notes::Draft::default()), - ui_fonts: uifonts::UiFonts::default(), - thumb_drag: None, - selpane: selpane::State::default(), - align_to: None, - doc_window: None, - sorter: sorter::State::default(), - slides_focused: false, - v_seen: false, - recent_checked: HashMap::new(), - twisting: false, - next_file_id: 1, - pending_files: Vec::new(), - ime_tracker: egui::ime_context::ImeContextTracker::new(), - ime_pending: None, - ime_frame_viewport: None, - ime_viewport: None, - ime_composition: None, - ime_terminal_pending: None, - ime_cancelled: None, - ime_generation: 0, - ime_settles_seen: 0, - ime_interrupt_pending: false, - ime_reset: HashMap::new(), - } - } - - pub fn with_control(mut self, rx: Receiver) -> Self { - self.control_rx = Some(rx); - self - } - - pub fn set_status(&mut self, msg: impl Into) { - self.status = Some((msg.into(), now_ms())); - } - - /// Turn on the automatic check for a newer release at start-up (desktop shell only; tests and - /// the web build never touch the network on their own). - #[cfg(not(target_arch = "wasm32"))] - pub fn enable_update_checks(&mut self) { - updates_ui::enable(self); - } - - /// A message that must be seen wherever the user is: a toast, which also shows over the start - /// screen and the File tab, where there is no status bar. - pub fn notify(&mut self, msg: impl Into) { - self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() }); - } - - /// The transient status message while it is fresh; clears it once stale. Both the status bar - /// and the start screen draw it — the start screen has no status bar, and errors raised there - /// (a missing recent file, a failed sample) used to vanish (issue #172). - pub fn fresh_status(&mut self) -> Option { - let (msg, at) = self.status.clone()?; - if now_ms() - at < STATUS_LINGER_MS { - Some(msg) - } else { - self.status = None; - None - } - } - - /// Does this recent path exist on disk? Probed once per path per session and only on - /// desktop (the web build has no file system to ask): a stat on a dead network share can - /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — - /// opening still tries and reports, and a successful open re-probes. - pub fn recent_exists(&mut self, path: &str) -> bool { - #[cfg(target_arch = "wasm32")] - { - let _ = path; - true - } - #[cfg(not(target_arch = "wasm32"))] - { - if let Some(ok) = self.recent_checked.get(path) { - return *ok; - } - // A stat on a dead network location (UNC share, URL) can block for the OS timeout, - // so remote-looking paths are never probed: they stay unmarked and are judged when - // opened. A disconnected mapped drive would still stall once — accepted. - if path.starts_with("\\\\") || path.contains("://") { - return true; - } - let ok = std::path::Path::new(path).is_file(); - if self.recent_checked.len() > 64 { - let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); - self.recent_checked.retain(|p, _| live.contains(p)); - } - self.recent_checked.insert(path.to_string(), ok); - ok - } - } - - /// Drop a path from the Recent list (its file is gone or the user asked for it). - pub fn forget_recent(&mut self, path: &str) { - self.ui.recent.retain(|p| p != path); - self.recent_checked.remove(path); - } - - /// Run a command by id (UI commands here, everything else in the engine). Errors show in the - /// status bar and are returned. - pub fn run(&mut self, id: &str, params: Value) -> Result { - let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { - self.run_ui(id, ¶ms) - } else { - self.session.execute(id, ¶ms).map_err(|e| e.to_string()) - }; - match &r { - // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel - // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). - Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { - if let Some(t) = v.get("text").and_then(Value::as_str) { - self.pending_copy = Some(t.to_string()); - } - } - Ok(_) => {} - Err(e) => self.set_status(e.clone()), - } - self.drain_requests(); - r - } - - /// Return the current document count plus the active document uid, so callers can detect a - /// replacement document rather than only an index change. - pub(crate) fn docs_key(&self) -> (usize, Option) { - // The document uid, not just its index: closing one presentation and opening another - // must not look like the same document to the Backstage view or a queued input batch. - (self.session.documents().len(), self.session.active().map(|d| d.uid)) - } - - /// What a queued synthetic event is meant for. The control protocol hands out window - /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and - /// the next UI pass makes the event stale. - fn input_target(&self) -> InputTarget { - let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; - InputTarget { - documents: self.session.documents().len(), - active: self.session.active().map(|d| d.uid), - slide: self.session.active().map(|d| d.selection.slide), - view: self.ui.view, - zoom: self.ui.zoom.map(bits), - canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), - canvas_scale: bits(self.canvas_scale), - dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), - palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), - backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), - ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), - ribbon_collapsed: self.ui.ribbon_collapsed, - ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), - show: self.show.as_ref().map(show::Show::input_state), - opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), - } - } - - /// Queue one synthetic control-channel event. The target is recorded when the batch starts, - /// before any host command in the same logic pass can change the document. - pub fn queue_input(&mut self, event: egui::Event) { - let target = self.input_target(); - // A batch that was already injected keeps the target it was injected for until its UI - // pass consumes it or retracts it. New events queued meanwhile belong to the current - // target, so they wait in a separate batch instead of joining the old one and being - // cleared with it when that batch turns out to be stale. - if self.synthetic_pending { - if self.synthetic_next_target != Some(target) { - self.synthetic_next.clear(); - self.synthetic_next_target = Some(target); - } - self.synthetic_next.push(event); - return; - } - if self.synthetic.is_empty() { - self.synthetic_target = Some(target); - } else if self.synthetic_target != Some(target) { - // The unsent queue belongs to a target that has already changed; drop it (and any - // older batch still waiting behind it) rather than appending a click intended for the - // new target to a stale batch. - self.synthetic.clear(); - self.synthetic_next.clear(); - self.synthetic_next_target = None; - self.synthetic_target = Some(target); - } - self.synthetic.push(event); - } - - /// Events admitted by the control channel but not yet injected (including a batch waiting - /// behind the one currently in flight). - pub(crate) fn synthetic_queued_len(&self) -> usize { - self.synthetic.len() + self.synthetic_next.len() - } - - /// The File tab's view is open over a presentation (not just the start screen with nothing - /// open). - pub fn backstage_open(&self) -> bool { - self.backstage.is_some() && self.session.active().is_some() - } - - /// Opens the File tab's view on `page` (keeping its state when it is already open). - pub fn open_backstage(&mut self, page: backstage::Page) { - let key = self.docs_key(); - match &mut self.backstage { - Some(b) => b.go(page), - None => self.backstage = Some(backstage::Backstage::new(page, key)), - } - } - - /// Back to the presentation (no effect on the start screen, where nothing is open). - pub fn close_backstage(&mut self) { - if self.session.active().is_some() { - self.backstage = None; - } - } - - /// Keeps the File tab's view in step with the open presentations: it closes when one is - /// opened, created or closed (the result shows), and it is the start screen when none is open. - fn sync_backstage(&mut self) { - let key = self.docs_key(); - let has_doc = self.session.active().is_some(); - let stale = self.backstage.as_ref().is_some_and(|b| b.docs != key); - if has_doc && stale { - self.backstage = None; - } else if !has_doc && (stale || self.backstage.is_none()) { - self.backstage = Some(backstage::Backstage::new(backstage::Page::Home, key)); - } - } - - /// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved - /// changes (Save, Don't Save, Cancel), as PowerPoint does. - pub fn close_presentation(&mut self) { - match self.session.active() { - None => {} - Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")), - Some(_) => { - let _ = self.run("file.close", json!({})); - } - } - } - - /// The file name a save or export suggests: the presentation's title with `ext`. - fn suggested_name(&self, ext: &str) -> String { - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - format!("{name}.{ext}") - } - - /// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`, - /// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download - /// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The - /// presentation keeps its own file. Returns whether a file was written or handed over. - pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool { - let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false }; - let (ext, format, all) = match fmt { - "pdf" => ("pdf", "pdf", false), - "png" => ("png", "png", false), - "pngAll" => ("png", "png", true), - "jpeg" | "jpg" => ("jpg", "jpeg", false), - "pptx" => ("pptx", "pptx", false), - "outline" | "txt" => ("txt", "outline", false), - "rtf" => ("rtf", "rtf", false), - "deckcraft" => ("deckcraft", "deckcraft", false), - "gif" => ("gif", "gif", false), - other => { - self.set_status(format!("DeckCraft can't export “{other}”")); - return false; - } - }; - let suggested = self.suggested_name(ext); - if let Some(pick) = self.services.pick_save.as_mut() { - let Some(path) = pick(&suggested) else { return false }; - let mut params = json!({"path": path, "format": format, "all": all}); - if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) { - o.extend(x.clone()); - } - return match self.run("file.export", params) { - Ok(_) => { - self.set_status(format!("Exported {path}")); - true - } - Err(_) => false, - }; - } - let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; - let bytes = match format { - "png" | "jpeg" => { - // Every slide for "all slides", one download each, named like the desktop export (#64). - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - let mut any = false; - let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); - for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { - any = self.download(&file, Ok(bytes)) || any; - } - return any; - } - "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), - "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), - other => deckcraft_engine::cmd::file::save_bytes(&doc, other), - }; - self.download(&suggested, bytes) - } - - /// Hands `bytes` to the browser as a download (web); false (with the reason in the status - /// bar) when there are no bytes or no way to download. - fn download(&mut self, name: &str, bytes: deckcraft_engine::Result>) -> bool { - match (bytes, self.services.download.as_mut()) { - (Ok(b), Some(dl)) => { - dl(name, &b); - true - } - (Err(e), _) => { - self.set_status(e.to_string()); - false - } - (Ok(_), None) => { - self.set_status("Saving a copy needs a file picker, which this window doesn't have."); - false - } - } - } - - /// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's - /// layout, slides and options) is written to the temporary folder and opened in the system's - /// PDF viewer, which has the printers; on the web it downloads. - pub fn print_pdf(&mut self, params: Value) -> bool { - let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false }; - let mut params = if params.is_object() { params } else { json!({}) }; - if self.services.pick_save.is_none() { - // Web: the browser hands the PDF to the user. - let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); - return self.download(&format!("{title}.pdf"), bytes); - } - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); - if let Some(o) = params.as_object_mut() { - o.insert("path".into(), json!(path)); - o.insert("format".into(), json!("pdf")); - } - match self.run("file.export", params) { - Ok(_) => { - self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); - self.set_status("Opened the presentation as a PDF to print"); - true - } - Err(_) => false, - } - } - - fn flag(&mut self, params: &Value, cur: bool) -> bool { - params.get("on").and_then(Value::as_bool).unwrap_or(!cur) - } - - fn run_ui(&mut self, id: &str, p: &Value) -> Result { - match id { - "view.normal" => self.ui.view = ViewMode::Normal, - "view.outline" => self.ui.view = ViewMode::Outline, - "view.sorter" => self.ui.view = ViewMode::Sorter, - "view.notesPage" => self.ui.view = ViewMode::NotesPage, - "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), - "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), - "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), - "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => { - self.ui.guides = self.flag(p, self.ui.guides); - // A presentation without guides gets PowerPoint's two centre guides. - if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { - let _ = self.session.execute("guide.add", &json!({"defaults": true})); - } - } - "view.gridOptions" => { - if grid_options_touched(p) { - let spacing = match p.get("spacing") { - None => None, - Some(raw) => { - let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; - if !(1.0..=288.0).contains(&n) { - return Err("`spacing` must be from 1 to 288 points".into()); - } - Some(n) - } - }; - let snap = grid_flag(p, "snap")?; - let smart = grid_flag(p, "smartGuides")?; - let gridlines = grid_flag(p, "gridlines")?; - let guides = grid_flag(p, "guides")?; - if let Some(n) = spacing { - self.session.prefs.grid_spacing = n; - } - if let Some(b) = snap { - self.session.prefs.snap_to_grid = b; - } - if let Some(b) = smart { - self.session.prefs.smart_guides = b; - self.ui.smart_guides = b; - } - if let Some(b) = gridlines { - self.ui.gridlines = b; - } - if let Some(b) = guides { - self.ui.guides = b; - // As View › Guides: a presentation without guides gets the two centre guides. - if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { - let _ = self.session.execute("guide.add", &json!({"defaults": true})); - } - } - } else { - self.dialog = Some(dialogs::Dialog::new("gridOptions")); - } - return Ok(json!({ - "spacing": self.session.prefs.grid_spacing, - "snap": self.session.prefs.snap_to_grid, - "smartGuides": self.session.prefs.smart_guides, - "gridlines": self.ui.gridlines, - "guides": self.ui.guides, - })); - } - "view.zoomIn" | "view.zoomOut" => { - let cur = self.ui.zoom.unwrap_or(self.canvas_scale); - let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; - let next = if id == "view.zoomIn" { - steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) - } else { - steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) - }; - self.ui.zoom = Some(next); - } - "view.zoom" => { - let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; - self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); - } - "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), - "view.fit" => self.ui.zoom = None, - "review.thesaurusPane" => { - let word = match p.get("word").and_then(Value::as_str) { - Some(w) => w.to_string(), - None => self - .session - .execute("review.thesaurus", &json!({})) - .ok() - .and_then(|r| r["word"].as_str().map(String::from)) - .unwrap_or_default(), - }; - self.thesaurus_query = word; - self.ui.pane = Some("thesaurus".into()); - } - "view.pane" => { - let pane = p.get("pane").and_then(Value::as_str).map(String::from); - self.ui.pane = - if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; - if let Some(tab) = p.get("tab").and_then(Value::as_str) { - self.ui.format_tab = tab.to_string(); - } - } - "view.tab" => { - let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; - self.ui.tab = tab.to_string(); - self.ui.ribbon_collapsed = false; - } - "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, - "view.dark" => { - let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); - self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; - self.restyle = true; - } - "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), - "view.newWindow" => return doc_window::command(self, p), - "show.start" => { - if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { - self.start_custom_show(name, false); - } else { - let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); - self.start_show(from, false); - } - } - "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), - "show.end" => { - self.show = None; - self.media.stop_owned(media::Owner::Show); - } - "animation.preview" => { - if p.get("on").and_then(Value::as_bool) == Some(false) { - self.anim_preview = None; - return Ok(json!({"playing": false})); - } - let from = match p.get("from") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), - }; - return self.start_anim_preview(from); - } - "app.home" => { - let on = self.flag(p, self.backstage_open()); - if on { - self.open_backstage(backstage::Page::Home); - } else { - self.close_backstage(); - } - } - "app.backstage" => { - if !p.get("on").and_then(Value::as_bool).unwrap_or(true) { - self.close_backstage(); - return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})); - } - let page = match p.get("page").and_then(Value::as_str) { - Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?, - None => backstage::Page::Home, - }; - if page.needs_doc() && self.session.active().is_none() { - return Err(format!("File › {} needs an open presentation", page.label())); - } - self.open_backstage(page); - return Ok(json!({"page": page.id()})); - } - "app.closePresentation" => self.close_presentation(), - "app.print" => { - if self.session.active().is_none() { - return Err("nothing to print: no presentation is open".into()); - } - return Ok(json!({"printed": self.print_pdf(p.clone())})); - } - "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), - "app.checkUpdates" => { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::request_check(self); - #[cfg(target_arch = "wasm32")] - return Err("updates are handled by the browser: reload the page".into()); - } - "app.palette" => self.palette = Some((String::new(), 0)), - "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), - "app.openDialog" => self.pick_and_open(), - "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), - "app.smartArtPictureDialog" => { - if self.services.pick_open.is_none() { - return Err("picking a SmartArt picture needs the desktop app".into()); - } - self.pick_and_run("picture", "smartArt.picture", p); - } - "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), - "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), - "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), - "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), - "app.saveAsDialog" => self.save_as_dialog("deckcraft"), - "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), - "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), - "app.browseThemeDialog" => { - if self.session.active().is_none() { - return Err("open a presentation first".into()); - } - if self.services.pick_open.is_some() { - self.pick_and_insert("theme", "design.browseTheme"); - } else { - // The browser's asynchronous picker would open the file as a new document. - self.set_status("Browse for Themes needs the desktop app for now".to_string()); - } - } - "app.qat" => return qat::command(self, p), - "app.find" | "app.replace" if self.session.active().is_some() => { - let mut d = dialogs::Dialog::new("find"); - d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); - self.dialog = Some(d); - } - "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), - "app.compareDialog" => { - if self.session.active().is_none() { - return Err("open the presentation to compare first".into()); - } - if self.services.pick_open.is_some() { - self.pick_and_insert("open", "review.compare"); - } else { - // The browser's asynchronous picker would open the file as a new document. - self.set_status("Compare needs the desktop app for now".to_string()); - } - } - "app.dialog" => { - let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; - self.dialog = Some(dialogs::Dialog::new(d)); - } - "app.links" => { - use deckcraft_engine::links::*; - return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); - } - _ => return Err(format!("unknown UI command `{id}`")), - } - Ok(Value::Null) - } - - /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the - /// thumbnail pane has been clicked. - pub(crate) fn slides_list_focused(&self) -> bool { - self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) - } - - /// First slide Play from Start should open: the custom show or range, skipping hidden slides. - pub fn show_start_index(&self) -> usize { - self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) - } - - /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of - /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or - /// opens on the end screen. Hidden slides are allowed, as in "from current slide". - pub(crate) fn show_from(&self, p: &Value) -> Result, String> { - let from = match p.get("from") { - None | Some(Value::Null) => return Ok(None), - Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, - }; - let count = self.session.active().map_or(0, |d| d.doc.slides.len()); - if from >= count { - return Err(format!("no slide {from}")); - } - Ok(Some(from)) - } - - /// Leave animation preview, stop editor-owned media, and create a show at the requested slide - /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. - pub fn start_show(&mut self, from: usize, reading: bool) { - self.anim_preview = None; - self.media.stop_owned(media::Owner::Editor); - if let Some(d) = self.session.active() { - self.show_instance = self.show_instance.wrapping_add(1).max(1); - let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); - show.instance = self.show_instance; - self.show = Some(show); - } - } - - /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. - pub fn start_custom_show(&mut self, name: &str, reading: bool) { - let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); - let Some(state) = state else { return }; - let from = state.slide; - self.start_show(from, reading); - if let Some(show) = self.show.as_mut() { - show.state = state; - } - } - - /// The current slide's animations are playing on the editor canvas. - pub fn anim_preview_active(&self) -> bool { - self.anim_preview.is_some() - } - - /// `(slide, seconds played, sequence length)` while a preview is running. - pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { - self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) - } - - pub fn stop_anim_preview(&mut self) { - self.anim_preview = None; - } - - /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. - pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { - self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) - } - - /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence - /// (plus a short hold) has finished. - pub fn tick_anim_preview(&mut self, now: f64) -> bool { - let Some(p) = self.anim_preview.as_ref() else { return false }; - let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); - let alive = self.show.is_none() - && self.session.active().is_some_and(|d| { - d.uid == uid - && d.selection.target == deckcraft_engine::Target::Slides - && d.selection.slide == slide - && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) - }); - if !alive { - self.anim_preview = None; - return false; - } - let Some(p) = self.anim_preview.as_mut() else { return false }; - let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; - let start = *p.started.get_or_insert(now); - let elapsed = p.from + (now - start).max(0.0); - if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { - self.anim_preview = None; - return false; - } - p.elapsed = elapsed; - true - } - - /// Play the current slide's animations on the canvas, from effect `from` (index into the slide's - /// animations) or from the start. - fn start_anim_preview(&mut self, from: Option) -> Result { - let (uid, slide, ptr, timeline) = { - let Some(d) = self.session.active() else { - return Err("no presentation is open".into()); - }; - if d.selection.target != deckcraft_engine::Target::Slides { - return Err("animation preview plays the current slide".into()); - } - let slide = d.selection.slide; - let Some(slide_arc) = d.doc.slides.get(slide) else { - return Err("no slide to preview".into()); - }; - let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; - let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); - (d.uid, slide, ptr, timeline) - }; - if timeline.is_empty() { - self.anim_preview = None; - self.set_status("This slide has no animations"); - return Ok(json!({"playing": false})); - } - let from_secs = match from { - Some(i) => timeline.start_of(i).ok_or_else(|| format!("no effect {i} in this slide's click sequence"))?, - None => 0.0, - }; - if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { - self.ui.view = ViewMode::Normal; - } - self.close_backstage(); - let duration = timeline.total_duration(); - self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); - self.set_status("Previewing animations"); - Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) - } - - fn pick_and_open(&mut self) { - if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick("open") { - self.open_path_in_background(&path); - } - } else if self.services.open_async.is_some() { - let id = self.begin_external_file("open"); - if let Some(f) = self.services.open_async.as_mut() { - f("open", id); - } - } - } - - pub fn open_path(&mut self, path: &str) -> Result<(), String> { - self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; - self.note_recent(path); - Ok(()) - } - - /// Files the system hands over (the command line, a double-click through the file - /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide - /// show starts at once from where Play from Start begins, as PowerPoint does; anything else - /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that - /// couldn't be opened, with the reason. - pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { - let mut failed = Vec::new(); - let mut last = None; - for p in paths { - match self.open_path(p) { - Ok(()) => last = Some(p), - Err(e) => failed.push((p.clone(), e)), - } - } - if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { - self.start_show(self.show_start_index(), false); - } - failed - } - - /// A presentation was opened from `path`: first in the recent list, shown in Normal view. - pub(crate) fn note_recent(&mut self, path: &str) { - self.status = None; // an older error (a missing file just before this one) must not linger - self.recent_checked.remove(path); - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(RECENT_MAX); - self.ui.view = ViewMode::Normal; - } - - /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. - pub(crate) fn pick_transition_sound(&mut self) { - if self.services.pick_open.is_some() { - self.pick_and_insert("audio", "transition.sound"); - } - } - - /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the - /// current one (desktop only: the browser picker opens what it picks as a document). - fn pick_and_reuse(&mut self, keep: bool) { - let Some(pick) = self.services.pick_open.as_mut() else { - self.set_status("Reuse Slides needs the desktop app".to_string()); - return; - }; - let Some(path) = pick("open") else { return }; - // Read the file once: the list shown and the slides inserted come from the same bytes, even - // if the file changes while the dialog is open. Without a reader the path is used as is. - let mut source = json!({}); - match self.services.read.as_ref().map(|r| r(&path)) { - Some(Ok(bytes)) => { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - source["name"] = json!(name); - source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); - } - Some(Err(e)) => { - self.set_status(e.to_string()); - return; - } - None => source["path"] = json!(path), - } - // Offer the file's slides to choose from; all of them are ticked to begin with. - match self.session.execute("slide.reuseList", &source) { - Ok(list) => { - let mut d = dialogs::Dialog::new("reuseSlides"); - d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]}); - self.reuse_target = Some(self.capture_file_target()); - self.dialog = Some(d); - } - Err(e) => self.set_status(e.to_string()), - } - } - - /// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that - /// `slide.reuseList` listed) after the slide that was current when the picker opened, in that - /// presentation, even if another one is active now; if it is closed or the slide is gone, - /// nothing is inserted. Returns whether they were inserted. - pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool { - let mut args = if source.is_object() { source.clone() } else { json!({}) }; - args["keepFormatting"] = json!(keep); - args["slides"] = json!(picked); - let result = match self.reuse_target { - Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args), - _ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()), - }; - match result { - Ok(()) => { - self.reuse_target = None; - self.set_status(format!("Inserted {} slides", picked.len())); - true - } - Err(e) => { - self.set_status(e); - false - } - } - } - - fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { - self.pick_and_run(purpose, cmd, &json!({})); - } - - /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. - fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { - if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick(purpose) { - let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; - let r = match self.services.read.as_ref().map(|r| r(&path)) { - Some(Ok(bytes)) => { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - args["name"] = json!(name); - args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); - self.session.execute(cmd, &args) - } - _ => { - args["path"] = json!(path); - self.session.execute(cmd, &args) - } - }; - if let Err(e) = r { - self.set_status(e.to_string()); - } - } - } else if self.services.open_async.is_some() { - let id = self.begin_external_file(purpose); - // Change Picture: the picture that arrives replaces the selected one (#115). - if cmd == "picture.change" - && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) - { - r.purpose = FilePurpose::ChangePicture; - } - if let Some(f) = self.services.open_async.as_mut() { - f(purpose, id); - } - } - } - - pub fn save_as_dialog(&mut self, format: &str) { - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - let ext = if format == "pptx" { "pptx" } else { "deckcraft" }; - let suggested = format!("{name}.{ext}"); - if let Some(pick) = self.services.pick_save.as_mut() { - if let Some(path) = pick(&suggested) { - match self.session.execute("file.saveAs", &json!({"path": path})) { - Ok(_) => { - self.set_status(format!("Saved {path}")); - self.ui.recent.retain(|p| *p != path); - self.ui.recent.insert(0, path); - } - Err(e) => self.set_status(e.to_string()), - } - } - } else if let Some(dl) = self.services.download.as_mut() - && let Some(d) = self.session.active() - { - match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) { - Ok(bytes) => dl(&suggested, &bytes), - Err(e) => self.set_status(e.to_string()), - } - } - } - - /// Save every presentation with unsaved changes (asking for a location where needed). - /// Returns false if one wasn't saved (cancelled or failed). - pub fn save_all(&mut self) -> bool { - let dirty: Vec = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect(); - for i in dirty { - self.session.set_active(i); - self.save(); - if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) { - return false; - } - } - true - } - - pub fn save(&mut self) { - if self.session.active().and_then(|d| d.path.clone()).is_some() { - match self.session.execute("file.save", &json!({})) { - Ok(_) => self.set_status("Saved"), - Err(e) => self.set_status(e.to_string()), - } - } else { - self.save_as_dialog("deckcraft"); - } - } - - /// Act on requests commands left for the UI. - fn drain_requests(&mut self) { - for r in std::mem::take(&mut self.session.ui_requests) { - match r { - UiRequest::Dialog { id, params } => { - let mut d = dialogs::Dialog::new(&id); - d.params = params; - self.dialog = Some(d); - } - UiRequest::Pane { id } => self.ui.pane = Some(id), - UiRequest::StartShow { from } => self.start_show(from, false), - UiRequest::Message { text } => self.set_status(text), - UiRequest::PickFile { purpose } => { - if purpose == "saveAs" { - self.save_as_dialog("deckcraft"); - } - } - UiRequest::Media { action, shape, ms } => { - let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; - // The command resolved `shape` on the editor's slide, also while a show runs. - if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { - self.media.request(uid, &doc, slide, &action, shape, ms, owner); - } - } - } - } - } - - fn drain_inbox(&mut self) { - let Some(inbox) = self.services.inbox.clone() else { return }; - let events = match inbox.lock() { - Ok(mut v) => std::mem::take(&mut *v), - Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), - }; - for event in events { - match event { - InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), - InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), - } - } - } - - /// Remember the open presentation, the slide (or master/layout) and the operation for a - /// file the host will read asynchronously. Returns the cancellation identity the host must - /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the - /// bytes arrive. - pub fn begin_external_file(&mut self, purpose: &str) -> u64 { - let id = self.next_file_id; - self.next_file_id = self.next_file_id.wrapping_add(1).max(1); - self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); - id - } - - /// A dropped or picked file read synchronously: presentations open, media is inserted on - /// the slide that is current now. - pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { - let id = self.begin_external_file("drop"); - self.apply_external_file(id, name, bytes); - } - - fn capture_file_target(&self) -> FileTarget { - let Some(d) = self.session.active() else { return FileTarget::Absent }; - let place = match d.selection.target { - Target::Slides => match d.current_slide() { - Some(s) => FilePlace::Slide(s.id), - None => FilePlace::Unspecified, - }, - Target::Master { master } => match d.doc.masters.get(master) { - Some(m) => FilePlace::Master(m.id), - None => FilePlace::Unspecified, - }, - Target::Layout { master, layout } => match d.doc.masters.get(master) { - Some(m) => match m.layouts.get(layout) { - Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, - None => FilePlace::Unspecified, - }, - None => FilePlace::Unspecified, - }, - t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), - }; - FileTarget::Document { doc: d.uid, place } - } - - /// True when the user is still looking at the place this request captured. - fn viewing_target(&self, target: &FileTarget) -> bool { - let FileTarget::Document { doc, place } = target else { return false }; - let Some(d) = self.session.active() else { return false }; - if d.uid != *doc { - return false; - } - match place { - FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), - FilePlace::Master(id) => { - matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) - } - FilePlace::Layout { master, layout } => { - matches!(d.selection.target, Target::Layout { master: mi, layout: li } - if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) - } - FilePlace::NotesPage(t) => d.selection.target == *t, - FilePlace::Unspecified => false, - } - } - - fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { - let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; - let req = self.pending_files.remove(pos); - let cmd = match command_for(req.purpose, name, bytes) { - Ok(cmd) => cmd, - Err(e) => { - self.set_status(e); - return; - } - }; - let params = if cmd == "slide.fromOutline" { - json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) - } else { - json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) - }; - let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { - self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) - } else { - self.execute_on_target(&req.target, name, cmd, ¶ms) - }; - match result { - Err(e) if cmd == "file.openBytes" => self.notify(e), - Err(e) => self.set_status(e), - Ok(()) => {} - } - self.drain_requests(); - } - - /// Run `cmd` on the document and slide captured for the request, then put the view back - /// if the user has since moved. A missing document or slide cancels the file. - fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { - let FileTarget::Document { doc, place } = target else { - self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; - return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); - }; - let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { - return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); - }; - let view = self.session.active_index(); - let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); - self.session.set_active(idx); - let located = match self.locate_place(place) { - Ok(located) => located, - Err(e) => { - self.restore_file_view(view); - return Err(format!("{name} was not applied: {e}")); - } - }; - if let Some(st) = self.session.active_mut() { - st.selection.target = located.target; - if let Some(slide) = located.slide { - st.selection.slide = slide; - st.selection.slides.clear(); - } - st.selection.shapes.clear(); - st.selection.text = None; - st.selection.cells = None; - } - let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); - if view == Some(idx) { - if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { - st.selection = sel; - } - } else { - self.restore_file_view(view); - } - result - } - - fn restore_file_view(&mut self, view: Option) { - if let Some(i) = view - && i < self.session.documents().len() - { - self.session.set_active(i); - } - } - - fn locate_place(&self, place: &FilePlace) -> Result { - let d = self.session.doc().map_err(|e| e.to_string())?; - match place { - FilePlace::Slide(id) => { - let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) - } - FilePlace::Master(id) => { - let master = - d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Master { master }, slide: None }) - } - FilePlace::Layout { master, layout } => { - let mi = - d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; - let li = d - .doc - .masters - .get(mi) - .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) - .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) - } - FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), - FilePlace::Unspecified => Err("there was no slide to put it on".into()), - } - } - - /// Keep a hidden show playing from logic alone. `run_logic` does not refresh - /// `InputState::time`, so the show anchors its last visible time to this monotonic instant - /// and advances from there. A forced hidden paint draws the current state but does not - /// advance it a second time, and no paused-show product policy is invented. - fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { - let visible = show::viewport_visible(ctx); - let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; - let Some(show) = self.show.as_mut() else { return }; - if visible { - // A visible logic-only pass (`run_logic`) carries the last full frame's input time. - // Anchoring that stale time to `now` would move the hidden clock backwards if the - // window hides before the next full pass. `show::ui` already records the anchor on - // every visible full pass, where `InputState::time` is current. - return; - } - if !show.hidden_started() { - return; - } - let input_time = ctx.input(|i| i.time); - let show_time = show.hidden_logic_time(input_time, now); - let keep = show.advance_hidden(&doc, show_time); - let time_driven = show.hidden_time_driven(&doc); - if !keep { - self.show = None; - return; - } - if time_driven { - // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the - // logic clock ticking; with no paint request pending eframe services it as a - // logic-only pass, not a visible frame. - ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); - } - } - - /// Background work, run on every logic call (visible, hidden or `run_logic`). - /// - /// Only consumed queues and monotonic deadlines live here: control requests, asynchronous - /// opening/inbox results, screenshot completions/deadlines, AutoRecover pacing and media - /// clocks. UI events are handled in [`Self::ui`], where a forced hidden paint also arrives. - pub fn logic(&mut self, ctx: &egui::Context) { - if !Self::root_viewport_admitted(ctx) { - log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id()); - return; - } - self.tick_hidden_show(ctx, web_time::Instant::now()); - // `raw_input_hook` runs before `App::logic`. In a full pass egui has already built - // `PointerState` and `keys_down` from every injected event, so retracting the events after - // this pass would not stop the derived click or key from reaching the UI. Defer instead: - // a control request, background-open result or inbox file that changes the document, - // target or modal owner waits until the admitted batch has been consumed by the old - // target's UI pass. A logic-only callback carries the RawInput into the next full pass - // (no live `InputState` here), so that pass still drains normally and the next - // `raw_input_hook` retracts a batch that went stale. - if !self.synthetic_batch_is_live(ctx) { - self.drain_control(ctx); - self.poll_opening(); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::tick(self, ctx); - // Inbox results are host file bytes and do not need fonts or a painted frame. A - // window that starts hidden must still apply a file picked before its first UI pass. - self.drain_inbox(); - } - self.drain_screenshots(); - // `raw_input_hook` ran before this logic pass. This is normally a no-op now that - // target-changing work is deferred for a live batch; it still covers a target change - // made by other logic, and keeps a logic-only batch pending for the next hook. - self.retract_synthetic_after_logic(ctx); - // Queued/settling screenshots force the bounded hidden paints they need; issued ones - // rely on the screenshot command and the completion callback, with only a deadline wake. - self.capture.request_paint_if_needed(ctx, web_time::Instant::now()); - if !self.synthetic.is_empty() || !self.synthetic_next.is_empty() || self.synthetic_pending { - ctx.request_repaint(); - } - // Closing the window with unsaved changes asks first. - if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && self.session.documents().iter().any(|d| d.is_dirty()) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - self.show = None; - if self.dialog.as_ref().is_none_or(|d| d.id != "quit") { - self.dialog = Some(dialogs::Dialog::new("quit")); - } - } - self.autosave_if_due(ctx); - self.tick_media_clock(ctx); - self.note_settled_composition(ctx); - // In a full run_ui pass the UI callback after this logic half still owns the pass's final - // IME descriptor. Only a standalone run_logic callback proves that no custom editor was - // painted for this tick. Do not infer this from cumulative frame counters or stale - // geometry. - if !self.ime_in_ui_pass(ctx) { - self.finalize_ime_logic(ctx); - } - } - - /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because - /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. - fn ui_setup(&mut self, ctx: &egui::Context) -> bool { - let scale = self.ui.ui_scale.clamp(0.5, 3.0); - if (ctx.zoom_factor() - scale).abs() > 1e-3 { - ctx.set_zoom_factor(scale); - } - if !self.styled { - theme::install_fonts_with(ctx, &self.ui_fonts.faces); - self.styled = true; - self.restyle = true; - // Font definitions take effect at the next pass boundary. Do not paint a language - // or symbol with the old font set while the new set is still pending. - ctx.request_discard("install interface fonts before layout"); - return false; - } - if self.restyle { - theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); - self.restyle = false; - } - self.fonts_ready = true; - true - } - - /// AutoRecover on a monotonic deadline, even when nothing is painted. - fn autosave_if_due(&mut self, ctx: &egui::Context) { - if self.session.recovery_dir.is_none() { - return; - } - let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); - let now = monotonic_secs(); - if now - self.last_recovery > every { - self.last_recovery = now; - if self.session.documents().iter().any(|d| d.is_dirty()) - && let Err(e) = self.session.execute("file.recovery.save", &json!({})) - { - self.set_status(format!("Couldn't save AutoRecover information: {e}")); - } - } - if self.session.documents().iter().any(|d| d.is_dirty()) { - let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); - ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); - } - } - - /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real - /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. - fn ui_end(&mut self, ctx: &egui::Context) { - self.ime_frame_viewport = None; - self.capture.observe_ui(ctx); - if !ctx.will_discard() { - let doc = self.session.active().map(|d| d.uid); - self.capture.issue(ctx, doc, web_time::Instant::now()); - } - if self.fonts_ready && !ctx.will_discard() { - self.synthetic_pending = false; - self.synthetic_injected.clear(); - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_focus = None; - if self.synthetic.is_empty() { - self.synthetic_target = None; - } - self.promote_next_synthetic(); - } - if self.fonts_ready && !ctx.will_discard() { - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; - } - } - - /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open - /// presentations' names and text, recent files, and what is typed or composed this frame. - fn note_ui_text(&mut self, ctx: &egui::Context) { - let typed: String = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), - egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), - _ => None, - }) - .collect() - }); - self.ui_fonts.note(ctx, &typed); - for d in self.session.documents() { - self.ui_fonts.note(ctx, &d.title()); - } - for r in &self.ui.recent { - self.ui_fonts.note(ctx, r); - } - if let Some(st) = self.session.active() { - self.ui_fonts.note_doc(ctx, st); - } - self.ui_fonts.apply(ctx); - } - - /// Stable IME owner for the canvas text target: document, view, shape and table cell. - /// - /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary - /// edits and caret movement keep the same owner. A new document, shape or cell is a new - /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key - /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is - /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. - fn ime_target(&self) -> Option { - let st = self.session.active()?; - let (shape, cell, notes) = match &st.selection.text { - Some(t) => (t.shape, t.cell, t.notes), - None => (*st.selection.shapes.first()?, None, false), - }; - if st.selection.text.is_none() && st.selection.shapes.len() != 1 { - return None; - } - let target = match st.selection.target { - Target::Slides => (0u8, 0usize, 0usize), - Target::Master { master } => (1, master, 0), - Target::Layout { master, layout } => (2, master, layout), - Target::Handout => (3, 0, 0), - Target::NotesMaster => (4, 0, 0), - }; - let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); - Some(egui::ime_context::ImeTarget::new(owner, 0)) - } - - /// The committed surrounding snapshot for the current edit target, or `Unavailable` when - /// there is an owner but no committed text to describe. - /// - /// An unavailable stamp carries the document revision. The tracker requires a strictly - /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a - /// wide selection or a commit that starts from a selected shape would poison the whole frame. - fn ime_receipt_stamp(&self) -> Option { - let st = self.session.active()?; - self.ime_target()?; - Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( - || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), - |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), - )) - } - - /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any - /// earlier or later IME receipt in the same frame. - fn note_application_edit(&mut self, ctx: &egui::Context) { - let Some(viewport) = self.ime_frame_viewport else { return }; - if let Some(target) = self.ime_target() { - let frame = ctx.cumulative_frame_nr_for(viewport); - self.ime_tracker.note_application_change(viewport, frame, target); - } - } - - /// Record the expected selection after an accepted IME event. Its own deletion may move - /// the caret/revision; that is not an application interruption of the following commit. - fn refresh_ime_terminal_selection(&mut self) { - let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); - if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { - pending.revision = revision; - pending.anchor = anchor; - pending.caret = caret; - } - } - - /// The current live destination for an IME reset fence. - fn ime_destination(&self) -> Option { - let st = self.session.active()?; - let target = self.ime_target()?; - let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); - Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) - } - - /// Use the recorded IME viewport only while it matches the current context viewport. - fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { - self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) - } - - /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to - /// another viewport's phase. - pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { - self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) - } - - /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. - fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { - if matches!(phase, ImeResetPhase::Clear) { - let _ = self.ime_reset.remove(&viewport); - } else { - self.ime_reset.insert(viewport, phase); - } - } - - /// The live stamp used to compare against the origin/interrupted terminal destination. - fn ime_terminal_stamp(&self) -> Option { - let st = self.session.active()?; - let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); - Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) - } - - /// Positive route predicate for the canvas IME producer: this pass must be one that can - /// actually paint `canvas::show` for the active document, with no modal, stock field or - /// overlay owning input. - /// - /// Sorter and Notes Page are editor views without a canvas producer, so they are named here - /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or - /// closing window, a missing document/target, a focused stock field, modal dialog, palette, - /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the - /// authority for this pass's actual descriptor; keeping this predicate positive means a view - /// added later is not admitted by omission simply because it does not call `canvas::show`. - fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { - matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) - && self.session.active().is_some() - && self.ime_target().is_some() - && ctx.input(|i| i.viewport().focused.unwrap_or(true)) - && ctx.input(|i| i.viewport().visible() != Some(false)) - && !ctx.input(|i| i.viewport().close_requested()) - && ctx.memory(|m| m.focused().is_none()) - && self.dialog.as_ref().is_none_or(|d| !d.modal()) - && self.palette.is_none() - && self.backstage.is_none() - && self.show.is_none() - && self.opening.is_none() - } - - /// The singleton app's session, composition tracker and terminal guard belong to its owning - /// viewport. View › New Window and Presenter View render through their own immediate - /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only - /// viewport mutate root composition state while only the reset map is per-viewport. Reject it - /// before any state changes instead of pretending to support multi-view editing. - fn root_viewport_admitted(ctx: &egui::Context) -> bool { - ctx.viewport_id() == egui::ViewportId::ROOT - } - - /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than - /// standalone logic. - fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { - let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); - ctx.data(|d| d.get_temp::(key).unwrap_or(false)) - } - - /// A new application-origin interruption supersedes any previous pending fence. - /// - /// This runs from the logic half of a full pass and from raw input handling, before the - /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: - /// putting a token into egui `Memory` now would let egui attach it to a later stock - /// descriptor, or to a canvas this route does not paint. Retire the superseded id - /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may - /// allocate its one replacement after confirming an eligible custom output. - fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { - let viewport = self.ime_viewport_or(ctx); - if !fresh - && matches!( - self.ime_reset_for(viewport), - ImeResetPhase::Awaiting { viewport: guard_viewport, .. } - | ImeResetPhase::Suspended { viewport: guard_viewport, .. } - if guard_viewport == viewport - ) - { - // This viewport already has a live or suspended guard. The finalizer replaces it - // once if the actual destination changed; do not churn a request per event. - return; - } - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - - /// Custom admission was lost on this route. Retire the old id without inventing an - /// acknowledgement and block until a later eligible pass can request a replacement. - fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { - if self.ime_reset_for(viewport).blocks_custom_input() { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - - /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are - /// inert; this never mutates text, caret or undo history. - fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { - let viewport = self.ime_viewport_or(ctx); - if request_id.viewport_id() != viewport { - return; - } - if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) - && id == request_id - && waiting_viewport == viewport - { - self.set_ime_reset(viewport, ImeResetPhase::Clear); - // The exact ordered completion is the platform-reset boundary. It also releases a - // cleared-preedit terminal guard: the app no longer has to wait for the old - // Commit/Enabled/Disabled before a fresh composition may start. Events ordered - // before this marker remain rejected; a late old terminal after the reset has the - // same documented residual as any other event crossing a platform reset. - if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { - self.ime_terminal_pending = None; - } - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - } - - /// Controls-only routing for a route where the custom editor cannot mutate. Markers are - /// visited in raw order; stock text/key events stay untouched for the stock widgets. - fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { - for e in events { - if let egui::Event::ImeResetCompleted { request_id } = e { - self.ime_reset_completed(*request_id, ctx); - } - } - } - - /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. - fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { - let viewport = self.ime_viewport_or(ctx); - let phase = self.ime_reset_for(viewport); - let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); - let destination = self.ime_destination(); - // The finalizer is the allocation authority, so it rechecks the positive canvas route - // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, - // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout - // discard or a missing target must suspend without putting a token into egui Memory. - let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); - let custom_output = match (eligible, custom, actual, destination) { - (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), - _ => None, - }; - let Some((output_context, destination)) = custom_output else { - match phase { - ImeResetPhase::Clear => {} - ImeResetPhase::Awaiting { .. } => { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { - // A request that was not bound to the actual custom output in this pass is - // retired locally; a later eligible activation obtains a fresh one. A guard - // stamped for another viewport (or a request issued for one) is never reused. - if guard_viewport != viewport || request.is_some() { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - ImeResetPhase::AllocationFailed { .. } => { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - return; - }; - match phase { - ImeResetPhase::Clear => {} - ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { - if old_viewport != viewport || old_destination != destination || old_context != output_context { - self.request_ime_rearm(ctx, viewport, destination, output_context); - } - } - ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { - let request = if needs_rearm && guard_viewport == viewport { - // Only a request issued for this exact viewport may be bound here. - request.filter(|id| id.viewport_id() == viewport) - } else { - None - }; - match request.or_else(|| ctx.request_ime_reset(viewport)) { - Some(id) => { - self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); - } - None => { - self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); - } - } - } - ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { - // Repeated idle output for the same destination does not retry allocation. - if old_viewport != viewport || old_destination != destination || old_context != output_context { - self.request_ime_rearm(ctx, viewport, destination, output_context); - } - } - } - } - - /// Request one replacement IME reset token and record `Awaiting` on success or - /// `AllocationFailed` when no token can be allocated. - fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { - match ctx.request_ime_reset(viewport) { - Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), - None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), - } - } - - /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying - /// stale input; the next full UI pass can rearm from its actual descriptor. - fn finalize_ime_logic(&mut self, ctx: &egui::Context) { - let viewport = self.ime_viewport_or(ctx); - if self.ime_reset_for(viewport).blocks_custom_input() { - // Only this viewport's guard is touched. A root/child logic tick must not suspend or - // rebind another viewport's independent reset lifetime. - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - - /// Note compositions the application settled since the last check (a click, a key, another - /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], - /// and a platform event for them is stale from that moment on, even in the same pass. - fn note_settled_composition(&mut self, ctx: &egui::Context) { - let settles = self.session.composition_settles(); - let current_stamp = self.ime_terminal_stamp(); - // `file.close` then `file.new` can replace the active uid before any pass observes the - // empty document. That is not a safe boundary for the old terminal: the platform may - // still deliver its suffix. Keep the guard bound to its retired owner and treat the new - // destination as a fresh interruption; only exact reset completion or an observed - // teardown may clear the terminal metadata. - let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { - let reference = if p.interrupted { - p.interrupted_at - } else { - Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) - }; - reference != current_stamp - }); - if settles == self.ime_settles_seen && !pending_interrupted { - return; - } - let fresh = settles != self.ime_settles_seen; - self.ime_settles_seen = settles; - if let Some(pending) = &mut self.ime_terminal_pending { - pending.cancelled = true; - pending.interrupted = true; - // Compare later passes against the destination where interruption actually happened, - // not forever against the retired composition's origin. The destination may be absent - // (the interruption closed its target); `interrupted` keeps that distinct from "not - // interrupted yet". - pending.interrupted_at = current_stamp; - } - self.ime_interrupt_pending = true; - self.note_ime_interruption(ctx, fresh); - let cancelled = self.ime_composition.as_mut().map(|c| { - c.settled = true; - (c.owner, c.generation) - }); - if let Some((owner, generation)) = cancelled { - // Retain a tombstone beyond the first stale event: clearing the composition must - // not let the rest of this input batch commit into the next target. `observe_ime` - // retires it in the pass that publishes the matching interrupt. - self.ime_cancelled = Some(ImeCancelled { owner, generation }); - } - } - - /// An input method's event for the slide's text (#101): the preedit is shown in the text and - /// replaced on each update; the commit replaces it as one undo step. - /// - /// An event only ever changes the target its composition began in. The application settles a - /// composition itself on a click, a key, a command or a target change, and the platform may - /// still hold it: pre-edits and commits queued for that generation in the settling input batch - /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is - /// selected now. After the interrupt has been published the platform is reset, so the next - /// batch is treated as new input; egui's event payload has no generation, so an OS-queued - /// event that crosses that reset cannot be distinguished from a legitimate one. - /// - /// A receipt is captured immediately before and after a successful commit or - /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn - /// [`egui::output::ImeChangeCause::InputMethod`]. - fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { - self.note_settled_composition(ctx); - let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); - let frame = ctx.cumulative_frame_nr_for(viewport); - let target = self.ime_target(); - let owner = target.map(|t| t.owner); - let accepted = - matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); - if self.reject_stale_ime_event(ctx, e, owner) { - return; - } - // Until the exact newest reset request completes at its ordered stream position, custom - // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do - // not release this guard; they still settle the old composition below. - #[allow(deprecated)] - if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { - return; - } - let before = if accepted { self.ime_receipt_stamp() } else { None }; - let r = self.apply_ime_event(e); - self.settle_ime_event_state(e, owner, r.is_ok()); - if r.is_ok() && accepted { - self.refresh_ime_terminal_selection(); - } - if let Err(e) = &r { - self.set_status(e.to_string()); - } else if accepted - && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) - && before != after - && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) - { - // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is - // not a mutation and must not poison the frame for a real commit later in the batch. - self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); - } - } - - /// Drop an event that belongs to a composition the application already settled, or to another - /// owner; returns `true` when the event must not be applied. - /// - /// A commit or deletion belongs to the composition that began the pre-edit: another owner - /// (the user clicked elsewhere) or a composition the app already settled makes it stale. - /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has - /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass - /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the - /// backend just the same), so a later commit-only is not lost forever: it is a - /// legitimate direct commit or a new platform composition. egui's events carry no - /// generation, so an event queued by the OS across that reset is indistinguishable from - /// one the user just typed; only events in the same input batch as the settle are rejected - /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner - /// remains guarded across that output boundary until its actual commit/enable/disable - /// arrives. Publishing a reset is not evidence that the separately queued terminal was - /// delivered. With no incoming event epoch, the first ambiguous new composition after an - /// unobserved reset may be rejected; no timer guesses that it became safe. - /// The tombstone is not filtered by the *current* target: a click can change the owner in - /// the settling pass, and every remaining event of that batch still belongs to the - /// pre-reset composition. Filtering would let the event after the first dropped pre-edit - /// start composing in the new target before the platform is reset. - fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { - let cancelled = self.ime_cancelled; - let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); - #[allow(deprecated)] - let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) - && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); - if !stale { - return false; - } - if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { - // The old terminal finally arrived: reject it and release its pending owner now, - // so a new preedit later in this same raw batch can belong to the current target. - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - return true; - } - if pending_cancelled { - // note_settled_composition already queued the reset. Do not also latch the - // frame's memory interrupt: a terminal later in this batch can consume it and - // permit a fresh preedit without resetting that new composition at pass end. - self.ime_composition = None; - return true; - } - if let Some(c) = cancelled { - log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); - } - self.ime_composition = None; - // Ask the backend to drop its copy, so this is one stale event and not a stream. - ctx.memory_mut(|m| m.interrupt_ime()); - true - } - - /// Apply the document mutation an IME event asks for, before any receipt or tracker state. - fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { - match e { - egui::ImeEvent::Preedit { text, active_range_chars } => { - self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) - } - // A commit only replaces text that is being edited. It must never start editing a - // merely selected shape: without a tracked pre-edit, a stale platform commit would - // replace that shape's whole text. - egui::ImeEvent::Commit(text) => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.commit_text(text) - } else { - Ok(Value::Null) - } - } - egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.delete_surrounding(*before_chars, *after_chars, false) - } else { - Ok(Value::Null) - } - } - egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.delete_surrounding(*before_bytes, *after_bytes, true) - } else { - Ok(Value::Null) - } - } - #[allow(deprecated)] - egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { - // The platform ended (or is about to restart) its composition itself. - self.session.end_composition(); - Ok(Value::Null) - } - } - } - - /// Update the tracked composition after `e` has been applied; `applied` says whether the - /// document mutation succeeded. - fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { - match e { - // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is - // left to interrupt. A pre-edit keeps (or starts) the tracked composition. - egui::ImeEvent::Commit(_) => { - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - #[allow(deprecated)] - egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { - // A platform-side disable/enable already reset the composition. Do not carry an - // older app-settled interrupt into the next (possibly new) composition. - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - egui::ImeEvent::Preedit { text, .. } => { - match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { - (Some(owner), true, true) => { - // Updating the same composition keeps its generation. After a settle (or - // a first pre-edit) this is a new generation and clears the tombstone. - let generation = match self.ime_composition { - Some(c) if c.owner == owner && !c.settled => c.generation, - _ => { - self.ime_generation = self.ime_generation.wrapping_add(1).max(1); - self.ime_generation - } - }; - self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); - self.ime_terminal_pending = None; - self.ime_cancelled = None; - } - (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { - // The model restored committed text, but no terminal was delivered. Keep - // this owner across passes; a later pointer/key/target change must reject - // its separately queued old delete/commit, even after an outgoing reset. - if let Some(st) = self.session.active() - && let Some(t) = &st.selection.text - { - self.ime_terminal_pending = Some(ImeTerminalPending { - owner, - revision: st.revision, - anchor: t.anchor, - caret: t.caret, - cancelled: false, - interrupted: false, - interrupted_at: None, - }); - } - } - _ => { - self.ime_composition = None; - self.ime_terminal_pending = None; - } - } - } - _ => {} - } - } - - /// Observe the canvas IME owner once per pass and publish its descriptor. - /// - /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, - /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means - /// the custom owner is hidden: the tracker is told so and the next focus starts a new - /// generation. The descriptor is rebuilt from the current committed text every pass. - /// - /// A composition the application settled itself is announced once as - /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That - /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit - /// terminal retains its separate pending owner until the incoming terminal is observed. - fn observe_ime(&mut self, ctx: &egui::Context) -> Option { - let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); - self.note_settled_composition(ctx); - // The editing window going away really ends the viewport's context; a merely hidden - // owner (modal, palette, unfocused field) stays observed as unfocused instead. - if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { - self.ime_interrupt_pending = false; - if let Some(old) = self.ime_viewport.take() { - self.ime_tracker.end_viewport(old); - // Explicit viewport destruction removes only that viewport's guard. - let _ = self.ime_reset.remove(&old); - } - let _ = self.ime_reset.remove(&viewport); - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_terminal_pending = None; - return None; - } - if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { - // The native window itself lost focus: no custom editor is eligible this pass, even - // if the model still has a text selection and a last-pass geometry rect. - self.ime_tracker.observe(viewport, None); - return None; - } - let Some(rect) = self.ime_pending.take() else { - // No canvas editor was painted in this pass. A discarded font/layout pass publishes - // nothing the platform sees, so keep the interrupt and tombstone for a later pass. - // An accepted pass is a backend reset only when it publishes *no* IME descriptor at - // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its - // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate - // retirement on the actual committed output, not on the absence of the canvas rect; - // otherwise the next legitimate commit in the focused field would be rejected. - let no_descriptor = ctx.output(|o| o.ime.is_none()); - if !ctx.will_discard() && no_descriptor { - self.ime_interrupt_pending = false; - self.ime_cancelled = None; - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - } - self.ime_tracker.observe(viewport, None); - return None; - }; - if !self.ime_custom_canvas_route(ctx) { - // The canvas can still paint behind an opening overlay or a Sorter/Notes Page - // reroute, and a forced hidden paint can run after the window lost focus or - // visibility. The positive route and the UI wrapper both reject those, so a pending - // rect must not republish the custom owner. - self.ime_tracker.observe(viewport, None); - return None; - } - let Some(st) = self.session.active() else { - self.ime_interrupt_pending = false; - self.ime_tracker.observe(viewport, None); - return None; - }; - let Some(target) = self.ime_target() else { - self.ime_interrupt_pending = false; - self.ime_cancelled = None; - self.ime_tracker.observe(viewport, None); - return None; - }; - if self.ime_viewport != Some(viewport) - && let Some(old) = self.ime_viewport.replace(viewport) - { - self.ime_tracker.end_viewport(old); - let _ = self.ime_reset.remove(&old); - } - let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); - let mut observation = - egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) - .purpose(egui::IMEPurpose::Normal) - .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); - if committed.is_none() { - observation = observation.unavailable_revision(st.revision); - } - let update = self.ime_tracker.observe(viewport, Some(observation))?; - // Clear the interrupt only once it is part of a settled pass's output descriptor. A - // discarded font/layout pass publishes nothing the platform sees, so keep it pending; - // that pass is not the boundary at which stale pre-edits become a new generation. - let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; - if interrupt { - // This pass's output carries the backend reset. The tombstone did its job for the - // events already processed in this batch; from the next batch on every pre-edit or - // commit is a new platform generation. A late event the OS queued across the reset - // has no generation in its payload, so it cannot be rejected without also dropping - // legitimate direct commits (documented residual in `ime_event`). - self.ime_cancelled = None; - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - } - let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); - ctx.output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - owner: update.owner, - context_generation: update.context_generation, - purpose: update.purpose, - rect, - cursor_rect: rect, - should_interrupt_composition: interrupt, - reset_request: None, - change_cause: update.change_cause, - surrounding_text: update.surrounding_text, - }); - }); - Some(key) - } - - /// Forget the injected or queued synthetic batch and everything recorded about it. Events - /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. - fn clear_synthetic_batch(&mut self) { - self.synthetic.clear(); - self.synthetic_pending = false; - self.synthetic_injected.clear(); - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_target = None; - self.synthetic_focus = None; - } - - /// Start the batch that was queued behind an older one once the older batch is gone. - fn promote_next_synthetic(&mut self) { - if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { - return; - } - self.synthetic = std::mem::take(&mut self.synthetic_next); - self.synthetic_target = self.synthetic_next_target.take(); - self.synthetic_focus = None; - } - - /// A font/layout pass will be discarded: egui already consumed the injected events but the - /// app deliberately lays nothing out, so put them back at the head of the queue. The next - /// frame injects them again after the new fonts are installed; otherwise a control click that - /// happened to arrive on the very first frame would be acknowledged without ever reaching the - /// UI. - fn requeue_injected_synthetic(&mut self) { - if !self.synthetic_pending { - return; - } - let mut combined = std::mem::take(&mut self.synthetic_injected); - combined.append(&mut self.synthetic); - self.synthetic = combined; - self.synthetic_pending = false; - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_focus = None; - if self.synthetic.is_empty() { - self.synthetic_target = None; - self.promote_next_synthetic(); - } - } - - /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried - /// first so an identical host event earlier in the batch is never removed; the last exact - /// match is only a fallback for a raw input that was rewritten around the events. - fn drain_synthetic_from(&self, events: &mut Vec) -> bool { - let injected = &self.synthetic_injected; - let n = injected.len(); - if n == 0 { - return false; - } - let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); - let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); - if let Some(at) = at { - events.drain(at..at + n); - true - } else { - false - } - } - - /// Drop an injected batch whose document, canvas, focus or modal owner changed during - /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained - /// there can still retarget the same `RawInput`. - /// - /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run - /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or - /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* - /// pass; remove the batch only when the live input carries exactly what this pass injected - /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending - /// bookkeeping so the next `raw_input_hook` can retract the events from the carried - /// `RawInput` before eframe delivers them in a full pass. - fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { - if !self.synthetic_pending { - return; - } - let current = self.input_target(); - let focus = ctx.memory(|m| m.focused()); - if self.synthetic_target == Some(current) && focus == self.synthetic_focus { - return; - } - let injected_time = self.synthetic_injected_time; - let mut removed = false; - ctx.input_mut(|i| { - // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the - // time of the RawInput this batch was injected into. Never drain an older batch that - // happens to look similar. - if i.raw.time != injected_time { - return; - } - let mut events = std::mem::take(&mut i.events); - let ok = self.drain_synthetic_from(&mut events); - i.events = events; - if ok { - // Keep `InputState::raw` consistent with what UI code reads as `events`. - let mut raw_events = std::mem::take(&mut i.raw.events); - let _ = self.drain_synthetic_from(&mut raw_events); - i.raw.events = raw_events; - removed = true; - } - }); - if removed { - self.clear_synthetic_batch(); - self.promote_next_synthetic(); - } else { - // Logic-only (or a pass whose input does not contain the batch): the events are in - // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the - // stale target so the next hook retracts them there. Events queued after the batch - // belong to the same stale target as well. - self.synthetic.clear(); - } - } - - /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is - /// the full pass the batch was admitted for and egui has already derived pointer/key state - /// from it. A logic-only callback leaves the previous pass's `InputState` (with another - /// `raw.time`) and must not be mistaken for the live pass, so control/background work is - /// still drained there and the next `raw_input_hook` retracts a batch that went stale. - fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { - if !self.synthetic_pending || self.synthetic_injected.is_empty() { - return false; - } - let Some(injected_time) = self.synthetic_injected_time else { return false }; - let injected = &self.synthetic_injected; - ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) - } - - /// Input that belongs to the pass about to run: native dropped files and one synthetic - /// control-channel batch. The synthetic batch is injected at most once; while it is - /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change - /// drops the whole queued batch rather than typing into the wrong place. - /// - /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will - /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe - /// carries the whole `RawInput` into the next full pass, so an already-injected batch is - /// retracted at the next hook if a control request changed the target, and otherwise is - /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase - /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - #[cfg(not(target_arch = "wasm32"))] - for f in std::mem::take(&mut raw.dropped_files) { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); - match f.bytes() { - Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), - Err(e) => self.set_status(e), - } - } - let focus = ctx.memory(|m| m.focused()); - if self.synthetic_pending { - let current = self.input_target(); - if self.synthetic_target != Some(current) || focus != self.synthetic_focus { - // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` - // and appends the next host input to it. The events are already in `raw.events`; - // clearing only the unsent queue would still deliver the stale click or key into - // the new document on the next full pass. Retract exactly the batch we appended. - let _ = self.drain_synthetic_from(&mut raw.events); - self.clear_synthetic_batch(); - // New control input queued for the current target while the stale batch was in - // flight is a separate batch; keep it instead of clearing it with the old one. - self.promote_next_synthetic(); - } else { - // This RawInput is the carrier for the next pass, and eframe refreshes its time - // on every call. Record the current time so `retract_synthetic_after_logic` can - // tell this full pass's live `InputState` from a logic-only callback's stale one. - self.synthetic_injected_time = raw.time; - } - } - if self.synthetic_pending || self.synthetic.is_empty() { - return; - } - let current = self.input_target(); - if self.synthetic_target != Some(current) { - // The batch was queued for a document/slide/canvas that has since changed. - self.synthetic.clear(); - self.synthetic_target = None; - self.promote_next_synthetic(); - if self.synthetic.is_empty() { - return; - } - if self.synthetic_target != Some(current) { - // The newer batch is stale too: drop it rather than delivering it to this target. - self.synthetic.clear(); - self.synthetic_target = None; - self.synthetic_next.clear(); - self.synthetic_next_target = None; - return; - } - } - self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); - log::debug!("injecting synthetic input batch {}", self.synthetic_generation); - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - let n = n.min(self.synthetic.len()); - let batch: Vec = self.synthetic.drain(..n).collect(); - let at = raw.events.len(); - raw.events.extend(batch.iter().cloned()); - self.synthetic_injected = batch; - self.synthetic_injected_at = Some(at); - self.synthetic_injected_time = raw.time; - self.synthetic_pending = true; - self.synthetic_focus = focus; - } - - /// The app shortcuts that still make sense on the empty File/start screen: create, open, - /// the command palette, preferences and the File tab itself. Document commands (save, find, - /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. - fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { - use egui::Key::*; - matches!( - (key, m.cmd, m.shift, m.alt), - (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) - ) - } - - /// File-tab navigation and document-free shortcuts, also used on the start screen. - fn route_backstage_keys(&mut self, events: Vec) { - if self.backstage.is_none() { - return; - } - // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app - // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the - // hidden presentation. - let start_screen = self.session.active().is_none(); - for e in events { - if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - let handled = backstage::key(self, key, mods) - // With no presentation the File home only owns the shortcuts that create or - // open one. Borrowing the whole document shortcut table here would also arm - // Save/Save As, hyperlink, find/replace, show and zoom against the empty - // start screen, which is a scope expansion no editing route needs. - || (mods.cmd || mods.alt) - && (!start_screen || Self::start_screen_shortcut(key, mods)) - && menus::ui_shortcut(self, key, mods); - if handled { - continue; - } - // Engine shortcuts that don't touch the hidden slide (Ctrl+N). - if mods.cmd - && matches!(key, egui::Key::N) - && let Err(e) = self.session.key(key_name(key), mods) - { - self.set_status(e.to_string()); - } - } - } - } - - /// Keyboard and clipboard for the slide (when no text field has focus). - fn keyboard(&mut self, ctx: &egui::Context) { - let events = ctx.input(|i| i.events.clone()); - // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the - // key press, so the release of a V press never seen is the paste. Tracked even while a text - // field has focus, so that a V typed there is not taken for one. - let mut image_paste = false; - let mut pasted_image_event = false; - for e in &events { - match e { - egui::Event::PasteImage(_) => { - pasted_image_event = true; - self.v_seen = true; - } - egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, - egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { - // Only a release with Command/Ctrl held is a paste: a bare V release is not. - let seen = std::mem::take(&mut self.v_seen); - image_paste = !seen && (modifiers.command || modifiers.ctrl); - } - _ => {} - } - } - let viewport = self.ime_viewport_or(ctx); - if self.session.active().is_none() { - // No document: keep slide-editing input suspended, while the start screen's - // File-tab navigation and document-free shortcuts remain available. - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { - self.route_backstage_keys(events); - } - self.drain_requests(); - return; - } - let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); - // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow - // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon - // buttons), and that stray focus would swallow every later key and end text entry. Let it - // go; a real text field or an open menu keeps its keys. - if !modal - && self.session.active().is_some_and(|d| d.selection.text.is_some()) - && !ctx.text_edit_focused() - && !ctx.any_popup_open() - && let Some(id) = ctx.memory(|m| m.focused()) - { - ctx.memory_mut(|m| m.surrender_focus(id)); - } - if ctx.memory(|m| m.focused().is_some()) || modal { - // A stock field/modal/palette owns input now. Retire the custom fence, then visit - // controls in raw order; stock text/key events are left for the stock widgets. - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - return; - } - if self.backstage.is_some() { - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - self.route_backstage_keys(events); - self.drain_requests(); - return; - } - let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; - if let Some(png) = img { - let data = deckcraft_engine::cmd::base64_encode(&png); - if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { - self.set_status(e.to_string()); - } - } - for e in events { - // An earlier command in this raw batch may have settled the composition. Request the - // replacement at this ordered boundary, before the current event is considered. - self.note_settled_composition(ctx); - match e { - egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), - egui::Event::PasteImage(image) => match color_image_png(&image) { - Ok(png) => { - let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); - if let Err(e) = self.session.execute("insert.picture", ¶ms) { - self.set_status(e.to_string()); - } - } - Err(e) => self.set_status(e), - }, - egui::Event::Text(t) => { - let revision = self.session.active().map(|d| d.revision); - if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) - && let Err(e) = self.session.type_text(&t) - { - self.set_status(e.to_string()); - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - egui::Event::Ime(ime) => self.ime_event(ctx, &ime), - egui::Event::Copy => self.publish_clipboard(ctx, false), - egui::Event::Cut => self.publish_clipboard(ctx, true), - egui::Event::Paste(text) => { - // Our own marker for objects without text: paste what the session copied. - let text = if text == OBJECTS_MARKER { String::new() } else { text }; - // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. - // Text keeps its HTML (bold, italic, underline) when the host can read it. - let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); - // egui 0.37 sends `PasteImage` directly; the services fallback stays for - // integrations that still send an empty `Paste`. Never insert twice. - let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; - let revision = self.session.active().map(|d| d.revision); - let r = match img { - Some(png) => self - .session - .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => { - let mut params = json!({"text": text}); - if let Some(h) = html { - params["html"] = Value::String(h); - } - self.session.execute("edit.paste", ¶ms) - } - }; - if let Err(e) = r { - self.set_status(e.to_string()); - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - egui::Event::Key { key, pressed: true, modifiers, .. } => { - if key == egui::Key::Tab { - // The slide has the keyboard, so Tab is ours (indent, tab character, next - // shape). Left to egui it would also focus the first ribbon widget, and - // every key after it would go there instead of the slide (#78). - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - } - if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { - self.anim_preview = None; - continue; - } - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { - continue; - } - let name = key_name(key); - let revision = self.session.active().map(|d| d.revision); - match self.session.key(name, mods) { - Ok(_) => {} - Err(e) => self.set_status(e.to_string()), - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - _ => {} - } - } - self.drain_requests(); - } - - /// Offer the copy or cut on the system clipboard. - /// - /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste - /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the - /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard - /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). - fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { - let cmd = if cut { "edit.cut" } else { "edit.copy" }; - let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; - let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); - let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) - { - return; - } - ctx.copy_text(system_clipboard_text(&text)); - } - - /// Lay out the whole window. - /// - /// Every return route, including fonts/layout discards, show mode, modal/Backstage and the - /// no-document paths, reaches the same outer reset observation and finalizer below. No early - /// route may publish or inspect IME output on its own. - pub fn ui(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - if !Self::root_viewport_admitted(&ctx) { - log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id()); - return; - } - self.ui_inner(ui); - // The last writer (all panels, dialogs, the custom producer and deferred child windows) - // has run. Observe the actual custom descriptor for this pass, then reconcile the reset - // lifetime against the actual committed output. This also runs for discarded passes. - self.ime_frame_viewport = Some(ctx.viewport_id()); - let custom = self.observe_ime(&ctx); - self.finalize_ime_reset(&ctx, custom); - } - - /// Run the admitted root UI pass, including setup gating, panels, canvas, dialogs, overlays, and - /// show mode. - fn ui_inner(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - self.ime_frame_viewport = Some(ctx.viewport_id()); - self.ime_pending = None; - if !self.ui_setup(&ctx) { - // This pass is discarded for the new fonts. The input events were already consumed - // by egui, so keep them queued for the next frame instead of silently losing them. - self.requeue_injected_synthetic(); - return; - } - let t0 = now_ms(); - let t = theme::Tokens::get(&ctx); - self.note_ui_text(&ctx); - self.media.sync_video_textures(&ctx); - canvas::trackpad_twist(self, &ctx); - eyedropper::tick(self, &ctx); - self.opening_ui(&ctx); - if self.show.is_some() { - self.anim_preview = None; - show::ui(self, ui); - doc_window::show(self, &ctx); - self.perf.frame_ms = now_ms() - t0; - self.ui_end(&ctx); - return; - } - self.sync_backstage(); - self.keyboard(&ctx); - self.sync_backstage(); - ribbon::title_bar(self, ui); - if self.backstage.is_none() && self.session.active().is_some() { - ribbon::show(self, ui); - status::status_bar(self, ui); - } - let now = ui.input(|i| i.time); - if self.tick_anim_preview(now) { - ctx.request_repaint(); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { - if self.backstage.is_some() { - // File tab: the Backstage view replaces everything below the title bar. - backstage::show(self, ui); - return; - } - if self.session.active().is_none() { - return; - } - match self.ui.view { - ViewMode::Sorter => sorter::show(self, ui), - ViewMode::NotesPage => sorter::notes_page(self, ui), - ViewMode::Outline | ViewMode::Normal | ViewMode::Reading => { - if self.ui.pane.is_some() { - panes::show(self, ui); - } - if self.ui.view == ViewMode::Outline { - sorter::outline_pane(self, ui); - } else { - thumbs::show(self, ui); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { - if self.ui.notes { - status::notes_pane(self, ui); - } - canvas::show(self, ui); - }); - } - } - }); - toast::note_missing_fonts(self); - toast::show(self, &ctx); - dialogs::show(self, &ctx); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::show(self, &ctx); - menus::palette(self, &ctx); - for url in std::mem::take(&mut self.pending_urls) { - ctx.open_url(egui::OpenUrl::new_tab(url)); - } - if let Some(t) = self.pending_copy.take() { - ctx.copy_text(t); - } - self.drain_requests(); - doc_window::show(self, &ctx); - self.perf.frame_ms = now_ms() - t0; - self.ui_end(&ctx); - } - - /// Media clocks. Voices belong to the open presentation: closing the last one, or switching - /// decks, stops them. The editor's media also stops when its slide is left. This runs from - /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. - fn tick_media_clock(&mut self, ctx: &egui::Context) { - let uid = self.session.active().map(|d| d.uid); - self.media.set_document(uid); - if !self.media.any_active() { - self.session.media_status.clear(); - return; - } - if self.show.is_none() - && let Some(d) = self.session.active() - { - let here: Vec = if d.selection.target == deckcraft_engine::Target::Slides { - media::slide_media(&d.doc, d.selection.slide).into_iter().map(|m| m.0).collect() - } else { - vec![] - }; - let gone: Vec<_> = - self.media.ids().into_iter().filter(|id| !here.contains(id) && self.media.owner(*id) == Some(media::Owner::Editor)).collect(); - for id in gone { - self.media.stop(id); - } - } - let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); - if playing { - ctx.request_repaint_after(std::time::Duration::from_millis(15)); - } - } - - pub fn open_url(&mut self, url: &str) { - self.pending_urls.push(url.to_string()); - } - - /// Drain pending control requests, executing ordinary requests and admitting screenshots for - /// later saving and reply. - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { - // A request whose sender gave up waiting (reply timeout) must not run late (#719). - if !req.take() { - continue; - } - let reply = req.reply.clone(); - let revision = self.session.active().map(|d| d.revision); - match control::handle(self, ctx, &req) { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { path } => { - let doc = self.session.active().map(|d| d.uid); - if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { - let _ = reply.send(json!({"ok": false, "error": e})); - } - } - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - self.control_rx = Some(rx); - } - - /// Save completed screenshots on the app thread and send exactly one reply each. - fn drain_screenshots(&mut self) { - let doc = self.session.active().map(|d| d.uid); - let completed = self.capture.drain(web_time::Instant::now(), doc); - for c in completed { - let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); - // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. - self.capture.release(c.reserved); - let _ = c.reply.send(v); - } - } -} - -/// Engine key name for an egui key. -pub fn key_name(key: egui::Key) -> &'static str { - use egui::Key::*; - match key { - Enter => "Enter", - Escape => "Escape", - Backspace => "Backspace", - Delete => "Delete", - Tab => "Tab", - ArrowLeft => "Left", - ArrowRight => "Right", - ArrowUp => "Up", - ArrowDown => "Down", - Home => "Home", - End => "End", - PageUp => "PageUp", - PageDown => "PageDown", - Space => "Space", - F2 => "F2", - F5 => "F5", - F7 => "F7", - other => other.name(), - } -} - -/// Seconds since this process's monotonic origin. -/// -/// `web_time::Instant` is `std::time::Instant` natively and `performance.now()` on wasm; it never -/// jumps backwards and is not `Date.now()` (which is wall time and may be zero/frozen in a -/// browser). Use this for deadlines, show/media clocks and duration displays. -static CLOCK_ORIGIN: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Monotonic seconds since the first call. -pub fn monotonic_secs() -> f64 { - CLOCK_ORIGIN.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() -} - -/// Monotonic milliseconds. Kept for status/toast durations and frame timings; never wall time. -pub fn now_ms() -> f64 { - monotonic_secs() * 1000.0 -} - -/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as -/// background removal. The image was already allocated by the integration; this keeps the -/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. -const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; - -/// Encode an egui color image as the PNG bytes `insert.picture` stores. -fn color_image_png(image: &egui::ColorImage) -> Result, String> { - let [w, h] = image.size; - let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; - if pixels == 0 { - return Err("the pasted image is empty".into()); - } - if pixels > MAX_PASTE_IMAGE_PIXELS { - return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); - } - let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); - Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) -} - -/// Images for the browser download: the current slide, or every slide (named `1.png`, -/// `2.png`... like the native export) when `all`. `transparent` leaves the slide -/// background out (PNG only, #784). -fn web_image_files( - doc: &deckcraft_model::Presentation, - current: usize, - all: bool, - name: &str, - ext: &str, - transparent: bool, -) -> Vec<(String, Vec)> { - let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; - let single = slides.len() == 1; - let scale = 2.0; - let dpi = deckcraft_render::dpi_for_scale(scale); - slides - .into_iter() - .enumerate() - .map(|(k, i)| { - let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; - let img = deckcraft_render::render_slide(doc, i, &opts); - let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; - let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; - (file, bytes) - }) - .collect() -} - #[cfg(test)] mod animpane_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 000000000..88f03ac2c --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,59 @@ +pub type ReadFn = Box Result, String>>; + +pub type WriteFn = Box Result<(), String>>; + +pub type PickFn = Box Option>; + +/// `(purpose, request id)`. The host shows a picker and later pushes an [`InboxEvent`] +/// carrying that same id. Completing a cancelled or unknown id does nothing: the file is +/// not applied to whichever document happens to be active. +pub type OpenAsyncFn = Box; + +pub type DownloadFn = Box; + +/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. +#[derive(Clone, Debug)] +pub struct InboxFile { + /// Cancellation identity. A file whose id was cancelled, or never started, is ignored. + pub id: u64, + pub name: String, + pub bytes: Vec, +} + +/// What the host delivers through [`Inbox`] after an asynchronous read. +#[derive(Clone, Debug)] +pub enum InboxEvent { + File(InboxFile), + /// The picker was dismissed or the file could not be read. `id` will not be applied. + Cancelled { + id: u64, + }, +} + +/// Files delivered asynchronously by the host (web file picker, dropped files). +pub type Inbox = std::sync::Arc>>; + +/// Platform services injected by the host (desktop or web). +#[derive(Default)] +pub struct Services { + /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. + pub pick_open: Option, + /// Save dialog with a suggested name → path. + pub pick_save: Option, + pub read: Option, + pub write: Option, + /// Asynchronous open dialog (web). The callback receives the purpose and the request id + /// from [`SlideApp::begin_external_file`]; the chosen file arrives through `inbox`. + pub open_async: Option, + /// Hand bytes to the user as a named file (browser download). + pub download: Option, + pub inbox: Option, + /// Image on the system clipboard (PNG bytes), when the host can read one. + pub clipboard_image: Option Option>>>, + /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`). + pub clipboard_html: Option Option>>, + /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. + pub clipboard_set_html: Option bool>>, + /// Audio output for media playback (cpal on desktop); without one media plays silently. + pub audio_out: Option>, +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 000000000..616769bc3 --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -0,0 +1,202 @@ +use super::*; + +/// The operation the user asked for. Kept with the request so a late completion does not +/// guess a command from the file name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FilePurpose { + Open, + Picture, + Audio, + Video, + /// Change Picture: the picture replaces the selected one (`picture.change`). + ChangePicture, + /// A drop, or any other purpose: decide from the name and bytes. + Infer, +} + +/// Where in a presentation a file should land. Ids, not indices: slides can move while +/// the browser is still reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FilePlace { + Slide(SlideId), + Master(MasterId), + Layout { + master: MasterId, + layout: LayoutId, + }, + /// The handout or notes master (one per presentation, so no id is needed). + NotesPage(Target), + /// The document was open but had no current slide, master or layout. + Unspecified, +} + +pub(crate) struct LocatedPlace { + pub(crate) target: Target, + pub(crate) slide: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ViewMode { + #[default] + Normal, + Outline, + Sorter, + NotesPage, + Reading, +} + +/// Persisted UI state. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct UiState { + pub brightness: theme::Brightness, + pub tab: String, + pub ribbon_collapsed: bool, + pub view: ViewMode, + pub notes: bool, + pub notes_height: f32, + pub thumbs_width: f32, + /// Vertical scroll offset of the thumbnail pane, recorded each frame for the control + /// channel and tests; not persisted. + #[serde(skip)] + pub thumbs_scroll: f32, + /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`, + /// `smartart` (Text Pane). + pub pane: Option, + pub pane_width: f32, + pub ruler: bool, + pub gridlines: bool, + /// Slides per page previewed in Handout Master view (1, 2, 3, 4, 6 or 9). + pub handout_per_page: u8, + pub guides: bool, + pub smart_guides: bool, + /// None = fit to window. + pub zoom: Option, + pub sorter_zoom: f32, + pub recent: Vec, + pub presenter_view: bool, + pub ui_scale: f32, + /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). + pub format_tab: String, + /// The Text Pane's current SmartArt line (Add Shape, Promote and Demote act on it). + pub smartart_row: usize, + /// A Text Pane line to focus once it is drawn (after Enter, Tab or Backspace rebuilt the list). + pub smartart_focus: Option, + /// The Text Pane's line drafts, for the SmartArt group they were read from. + pub smartart_drafts: (u32, Vec), + pub grayscale: bool, + /// Comment the Review Previous/Next buttons last landed on: `(slide, index)`. + #[serde(skip_serializing_if = "Option::is_none")] + pub comment_focus: Option<(usize, usize)>, + /// Quick Access Toolbar buttons, left to right. + pub qat: Vec, +} + +impl Default for UiState { + fn default() -> Self { + UiState { + brightness: theme::Brightness::Light, + tab: "Home".into(), + ribbon_collapsed: false, + view: ViewMode::Normal, + notes: true, + notes_height: 52.0, + thumbs_width: 236.0, + thumbs_scroll: 0.0, + pane: None, + pane_width: 300.0, + ruler: false, + gridlines: false, + handout_per_page: 6, + guides: false, + smart_guides: true, + zoom: None, + sorter_zoom: 1.0, + recent: vec![], + presenter_view: true, + ui_scale: 1.0, + format_tab: "fill".into(), + smartart_row: 0, + smartart_focus: None, + smartart_drafts: (0, vec![]), + grayscale: false, + comment_focus: None, + qat: qat::defaults(), + } + } +} + +/// The editor's zoom range (1 = 100 %). +pub const ZOOM_RANGE: std::ops::RangeInclusive = 0.1..=4.0; + +impl UiState { + /// The state with every value usable: `ui.json` is a file on disk, and a damaged or + /// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the + /// renderer for a texture larger than memory at start-up; panel sizes, scales and names are + /// brought into range the same way, and the recent list is cut to its usual length. + pub fn sanitized(mut self) -> UiState { + let d = UiState::default(); + let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end())); + self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom); + self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale); + self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height); + self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width); + self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width); + let short = |s: &String| s.len() <= 64; + if !short(&self.tab) { + self.tab = d.tab; + } + if !short(&self.format_tab) { + self.format_tab = d.format_tab; + } + self.pane = self.pane.filter(short); + self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); + self.recent.dedup(); + self.recent.truncate(RECENT_MAX); + self + } +} + +/// How many recent files File › Open and Home list. +pub const RECENT_MAX: usize = 12; + +/// The live target/selection stamp used to detect a new application interruption without +/// discarding the retired composition's origin. Owner is optional because an interruption can +/// remove the canvas target entirely. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ImeTerminalStamp { + pub(crate) owner: Option, + pub(crate) revision: u64, + pub(crate) anchor: Option<(usize, usize)>, + pub(crate) caret: Option<(usize, usize)>, +} + +/// The live canvas target and caret/selection stamp a reset fence is bound to. +/// +/// The retired composition's origin is deliberately absent: an app-originated interruption can +/// move the destination, and the replacement request belongs to the current destination. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ImeDestination { + pub(crate) owner: egui::Id, + pub(crate) revision: u64, + pub(crate) anchor: Option<(usize, usize)>, + pub(crate) caret: Option<(usize, usize)>, +} + +/// The actual custom descriptor identity a reset request is allowed to acknowledge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ImeDescriptorKey { + owner: egui::Id, + context_generation: u64, + purpose: egui::IMEPurpose, +} + +impl ImeDescriptorKey { + /// Construct the exact custom-IME descriptor identity from owner, context generation, and + /// purpose. + pub(crate) fn new(owner: egui::Id, context_generation: u64, purpose: egui::IMEPurpose) -> Self { + Self { owner, context_generation, purpose } + } +} From d6026de7c2d73ec360b152734fbfb91e9289fbcf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:34:51 +0000 Subject: [PATCH 22/90] refactor(ui-egui): move SlideApp method groups to app/{commands,files,ime,input,keys,playback,clipboard,frame,control}.rs --- crates/ui-egui/src/app.rs | 2503 +-------------------------- crates/ui-egui/src/app/clipboard.rs | 32 + crates/ui-egui/src/app/commands.rs | 276 +++ crates/ui-egui/src/app/control.rs | 45 + crates/ui-egui/src/app/files.rs | 664 +++++++ crates/ui-egui/src/app/frame.rs | 108 ++ crates/ui-egui/src/app/ime.rs | 703 ++++++++ crates/ui-egui/src/app/input.rs | 270 +++ crates/ui-egui/src/app/keys.rs | 251 +++ crates/ui-egui/src/app/playback.rs | 171 ++ 10 files changed, 2534 insertions(+), 2489 deletions(-) create mode 100644 crates/ui-egui/src/app/clipboard.rs create mode 100644 crates/ui-egui/src/app/commands.rs create mode 100644 crates/ui-egui/src/app/control.rs create mode 100644 crates/ui-egui/src/app/files.rs create mode 100644 crates/ui-egui/src/app/frame.rs create mode 100644 crates/ui-egui/src/app/ime.rs create mode 100644 crates/ui-egui/src/app/input.rs create mode 100644 crates/ui-egui/src/app/keys.rs create mode 100644 crates/ui-egui/src/app/playback.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0dcd5df03..c4b618d43 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -4,95 +4,25 @@ use super::services::{InboxEvent, Services}; use super::state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp, LocatedPlace, RECENT_MAX, UiState, ViewMode}; use super::*; -fn purpose_of(purpose: &str) -> FilePurpose { - match purpose { - "open" => FilePurpose::Open, - "picture" => FilePurpose::Picture, - "audio" => FilePurpose::Audio, - "video" => FilePurpose::Video, - _ => FilePurpose::Infer, - } -} +mod clipboard; +mod commands; +mod control; +mod files; +mod frame; +mod ime; +mod input; +mod keys; +mod playback; -fn is_presentation_name(lower: &str) -> bool { - lower.ends_with(".deckcraft") - || lower.ends_with(".slidecraft") - || lower.ends_with(".pptx") - || lower.ends_with(".potx") - || lower.ends_with(".ppsx") -} - -/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. -fn open_command(name: &str) -> Option<&'static str> { - let lower = name.to_ascii_lowercase(); - if is_presentation_name(&lower) { - Some("file.openBytes") - } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { - Some("slide.fromOutline") - } else { - None - } -} - -/// A drop has no requested operation, so the name and bytes decide: a presentation opens, -/// then a recognised picture, sound or video is inserted, then an outline becomes slides. -fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { - let lower = name.to_ascii_lowercase(); - if is_presentation_name(&lower) { - return Some("file.openBytes"); - } - let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); - if ct.starts_with("image/") { - Some("insert.picture") - } else if ct.starts_with("audio/") { - Some("insert.audio") - } else if ct.starts_with("video/") { - Some("insert.video") - } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { - Some("slide.fromOutline") - } else { - None - } -} - -fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { - let cmd = match purpose { - FilePurpose::Picture => Some("insert.picture"), - FilePurpose::Audio => Some("insert.audio"), - FilePurpose::Video => Some("insert.video"), - FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), - FilePurpose::Open => open_command(name), - FilePurpose::Infer => infer_command(name, bytes), - }; - cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) -} +#[cfg(test)] +pub(crate) use clipboard::{OBJECTS_MARKER, system_clipboard_text}; +#[cfg(test)] +pub(crate) use files::web_image_files; +pub use keys::key_name; /// How long a transient status message stays visible. const STATUS_LINGER_MS: f64 = 6000.0; -/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) -/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the -/// shortcut never reached the session and only the context menu could paste objects (#182). An -/// invisible word joiner, so a paste into another app adds nothing visible. -pub(crate) const OBJECTS_MARKER: &str = "\u{2060}"; - -/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. -pub(crate) fn system_clipboard_text(t: &str) -> String { - if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } -} - -fn grid_options_touched(p: &Value) -> bool { - ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) -} - -fn grid_flag(p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(format!("`{key}` must be true or false")), - } -} - impl SlideApp { /// Build application UI state, taking ownership of audio output and initialising media, dialogs, /// synthetic input, and timing fields. @@ -213,66 +143,6 @@ impl SlideApp { } } - /// Does this recent path exist on disk? Probed once per path per session and only on - /// desktop (the web build has no file system to ask): a stat on a dead network share can - /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — - /// opening still tries and reports, and a successful open re-probes. - pub fn recent_exists(&mut self, path: &str) -> bool { - #[cfg(target_arch = "wasm32")] - { - let _ = path; - true - } - #[cfg(not(target_arch = "wasm32"))] - { - if let Some(ok) = self.recent_checked.get(path) { - return *ok; - } - // A stat on a dead network location (UNC share, URL) can block for the OS timeout, - // so remote-looking paths are never probed: they stay unmarked and are judged when - // opened. A disconnected mapped drive would still stall once — accepted. - if path.starts_with("\\\\") || path.contains("://") { - return true; - } - let ok = std::path::Path::new(path).is_file(); - if self.recent_checked.len() > 64 { - let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); - self.recent_checked.retain(|p, _| live.contains(p)); - } - self.recent_checked.insert(path.to_string(), ok); - ok - } - } - - /// Drop a path from the Recent list (its file is gone or the user asked for it). - pub fn forget_recent(&mut self, path: &str) { - self.ui.recent.retain(|p| p != path); - self.recent_checked.remove(path); - } - - /// Run a command by id (UI commands here, everything else in the engine). Errors show in the - /// status bar and are returned. - pub fn run(&mut self, id: &str, params: Value) -> Result { - let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { - self.run_ui(id, ¶ms) - } else { - self.session.execute(id, ¶ms).map_err(|e| e.to_string()) - }; - match &r { - // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel - // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). - Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { - if let Some(t) = v.get("text").and_then(Value::as_str) { - self.pending_copy = Some(t.to_string()); - } - } - Ok(_) => {} - Err(e) => self.set_status(e.clone()), - } - self.drain_requests(); - r - } - /// Return the current document count plus the active document uid, so callers can detect a /// replacement document rather than only an index change. pub(crate) fn docs_key(&self) -> (usize, Option) { @@ -281,66 +151,6 @@ impl SlideApp { (self.session.documents().len(), self.session.active().map(|d| d.uid)) } - /// What a queued synthetic event is meant for. The control protocol hands out window - /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and - /// the next UI pass makes the event stale. - pub(crate) fn input_target(&self) -> InputTarget { - let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; - InputTarget { - documents: self.session.documents().len(), - active: self.session.active().map(|d| d.uid), - slide: self.session.active().map(|d| d.selection.slide), - view: self.ui.view, - zoom: self.ui.zoom.map(bits), - canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), - canvas_scale: bits(self.canvas_scale), - dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), - palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), - backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), - ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), - ribbon_collapsed: self.ui.ribbon_collapsed, - ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), - show: self.show.as_ref().map(show::Show::input_state), - opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), - } - } - - /// Queue one synthetic control-channel event. The target is recorded when the batch starts, - /// before any host command in the same logic pass can change the document. - pub fn queue_input(&mut self, event: egui::Event) { - let target = self.input_target(); - // A batch that was already injected keeps the target it was injected for until its UI - // pass consumes it or retracts it. New events queued meanwhile belong to the current - // target, so they wait in a separate batch instead of joining the old one and being - // cleared with it when that batch turns out to be stale. - if self.synthetic_pending { - if self.synthetic_next_target != Some(target) { - self.synthetic_next.clear(); - self.synthetic_next_target = Some(target); - } - self.synthetic_next.push(event); - return; - } - if self.synthetic.is_empty() { - self.synthetic_target = Some(target); - } else if self.synthetic_target != Some(target) { - // The unsent queue belongs to a target that has already changed; drop it (and any - // older batch still waiting behind it) rather than appending a click intended for the - // new target to a stale batch. - self.synthetic.clear(); - self.synthetic_next.clear(); - self.synthetic_next_target = None; - self.synthetic_target = Some(target); - } - self.synthetic.push(event); - } - - /// Events admitted by the control channel but not yet injected (including a batch waiting - /// behind the one currently in flight). - pub(crate) fn synthetic_queued_len(&self) -> usize { - self.synthetic.len() + self.synthetic_next.len() - } - /// The File tab's view is open over a presentation (not just the start screen with nothing /// open). pub fn backstage_open(&self) -> bool { @@ -376,945 +186,6 @@ impl SlideApp { } } - /// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved - /// changes (Save, Don't Save, Cancel), as PowerPoint does. - pub fn close_presentation(&mut self) { - match self.session.active() { - None => {} - Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")), - Some(_) => { - let _ = self.run("file.close", json!({})); - } - } - } - - /// The file name a save or export suggests: the presentation's title with `ext`. - fn suggested_name(&self, ext: &str) -> String { - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - format!("{name}.{ext}") - } - - /// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`, - /// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download - /// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The - /// presentation keeps its own file. Returns whether a file was written or handed over. - pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool { - let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false }; - let (ext, format, all) = match fmt { - "pdf" => ("pdf", "pdf", false), - "png" => ("png", "png", false), - "pngAll" => ("png", "png", true), - "jpeg" | "jpg" => ("jpg", "jpeg", false), - "pptx" => ("pptx", "pptx", false), - "outline" | "txt" => ("txt", "outline", false), - "rtf" => ("rtf", "rtf", false), - "deckcraft" => ("deckcraft", "deckcraft", false), - "gif" => ("gif", "gif", false), - other => { - self.set_status(format!("DeckCraft can't export “{other}”")); - return false; - } - }; - let suggested = self.suggested_name(ext); - if let Some(pick) = self.services.pick_save.as_mut() { - let Some(path) = pick(&suggested) else { return false }; - let mut params = json!({"path": path, "format": format, "all": all}); - if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) { - o.extend(x.clone()); - } - return match self.run("file.export", params) { - Ok(_) => { - self.set_status(format!("Exported {path}")); - true - } - Err(_) => false, - }; - } - let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; - let bytes = match format { - "png" | "jpeg" => { - // Every slide for "all slides", one download each, named like the desktop export (#64). - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - let mut any = false; - let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); - for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { - any = self.download(&file, Ok(bytes)) || any; - } - return any; - } - "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), - "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), - other => deckcraft_engine::cmd::file::save_bytes(&doc, other), - }; - self.download(&suggested, bytes) - } - - /// Hands `bytes` to the browser as a download (web); false (with the reason in the status - /// bar) when there are no bytes or no way to download. - fn download(&mut self, name: &str, bytes: deckcraft_engine::Result>) -> bool { - match (bytes, self.services.download.as_mut()) { - (Ok(b), Some(dl)) => { - dl(name, &b); - true - } - (Err(e), _) => { - self.set_status(e.to_string()); - false - } - (Ok(_), None) => { - self.set_status("Saving a copy needs a file picker, which this window doesn't have."); - false - } - } - } - - /// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's - /// layout, slides and options) is written to the temporary folder and opened in the system's - /// PDF viewer, which has the printers; on the web it downloads. - pub fn print_pdf(&mut self, params: Value) -> bool { - let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false }; - let mut params = if params.is_object() { params } else { json!({}) }; - if self.services.pick_save.is_none() { - // Web: the browser hands the PDF to the user. - let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); - return self.download(&format!("{title}.pdf"), bytes); - } - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); - if let Some(o) = params.as_object_mut() { - o.insert("path".into(), json!(path)); - o.insert("format".into(), json!("pdf")); - } - match self.run("file.export", params) { - Ok(_) => { - self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); - self.set_status("Opened the presentation as a PDF to print"); - true - } - Err(_) => false, - } - } - - fn flag(&mut self, params: &Value, cur: bool) -> bool { - params.get("on").and_then(Value::as_bool).unwrap_or(!cur) - } - - fn run_ui(&mut self, id: &str, p: &Value) -> Result { - match id { - "view.normal" => self.ui.view = ViewMode::Normal, - "view.outline" => self.ui.view = ViewMode::Outline, - "view.sorter" => self.ui.view = ViewMode::Sorter, - "view.notesPage" => self.ui.view = ViewMode::NotesPage, - "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), - "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), - "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), - "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => { - self.ui.guides = self.flag(p, self.ui.guides); - // A presentation without guides gets PowerPoint's two centre guides. - if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { - let _ = self.session.execute("guide.add", &json!({"defaults": true})); - } - } - "view.gridOptions" => { - if grid_options_touched(p) { - let spacing = match p.get("spacing") { - None => None, - Some(raw) => { - let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; - if !(1.0..=288.0).contains(&n) { - return Err("`spacing` must be from 1 to 288 points".into()); - } - Some(n) - } - }; - let snap = grid_flag(p, "snap")?; - let smart = grid_flag(p, "smartGuides")?; - let gridlines = grid_flag(p, "gridlines")?; - let guides = grid_flag(p, "guides")?; - if let Some(n) = spacing { - self.session.prefs.grid_spacing = n; - } - if let Some(b) = snap { - self.session.prefs.snap_to_grid = b; - } - if let Some(b) = smart { - self.session.prefs.smart_guides = b; - self.ui.smart_guides = b; - } - if let Some(b) = gridlines { - self.ui.gridlines = b; - } - if let Some(b) = guides { - self.ui.guides = b; - // As View › Guides: a presentation without guides gets the two centre guides. - if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { - let _ = self.session.execute("guide.add", &json!({"defaults": true})); - } - } - } else { - self.dialog = Some(dialogs::Dialog::new("gridOptions")); - } - return Ok(json!({ - "spacing": self.session.prefs.grid_spacing, - "snap": self.session.prefs.snap_to_grid, - "smartGuides": self.session.prefs.smart_guides, - "gridlines": self.ui.gridlines, - "guides": self.ui.guides, - })); - } - "view.zoomIn" | "view.zoomOut" => { - let cur = self.ui.zoom.unwrap_or(self.canvas_scale); - let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; - let next = if id == "view.zoomIn" { - steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) - } else { - steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) - }; - self.ui.zoom = Some(next); - } - "view.zoom" => { - let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; - self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); - } - "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), - "view.fit" => self.ui.zoom = None, - "review.thesaurusPane" => { - let word = match p.get("word").and_then(Value::as_str) { - Some(w) => w.to_string(), - None => self - .session - .execute("review.thesaurus", &json!({})) - .ok() - .and_then(|r| r["word"].as_str().map(String::from)) - .unwrap_or_default(), - }; - self.thesaurus_query = word; - self.ui.pane = Some("thesaurus".into()); - } - "view.pane" => { - let pane = p.get("pane").and_then(Value::as_str).map(String::from); - self.ui.pane = - if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; - if let Some(tab) = p.get("tab").and_then(Value::as_str) { - self.ui.format_tab = tab.to_string(); - } - } - "view.tab" => { - let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; - self.ui.tab = tab.to_string(); - self.ui.ribbon_collapsed = false; - } - "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, - "view.dark" => { - let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); - self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; - self.restyle = true; - } - "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), - "view.newWindow" => return doc_window::command(self, p), - "show.start" => { - if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { - self.start_custom_show(name, false); - } else { - let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); - self.start_show(from, false); - } - } - "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), - "show.end" => { - self.show = None; - self.media.stop_owned(media::Owner::Show); - } - "animation.preview" => { - if p.get("on").and_then(Value::as_bool) == Some(false) { - self.anim_preview = None; - return Ok(json!({"playing": false})); - } - let from = match p.get("from") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), - }; - return self.start_anim_preview(from); - } - "app.home" => { - let on = self.flag(p, self.backstage_open()); - if on { - self.open_backstage(backstage::Page::Home); - } else { - self.close_backstage(); - } - } - "app.backstage" => { - if !p.get("on").and_then(Value::as_bool).unwrap_or(true) { - self.close_backstage(); - return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})); - } - let page = match p.get("page").and_then(Value::as_str) { - Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?, - None => backstage::Page::Home, - }; - if page.needs_doc() && self.session.active().is_none() { - return Err(format!("File › {} needs an open presentation", page.label())); - } - self.open_backstage(page); - return Ok(json!({"page": page.id()})); - } - "app.closePresentation" => self.close_presentation(), - "app.print" => { - if self.session.active().is_none() { - return Err("nothing to print: no presentation is open".into()); - } - return Ok(json!({"printed": self.print_pdf(p.clone())})); - } - "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), - "app.checkUpdates" => { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::request_check(self); - #[cfg(target_arch = "wasm32")] - return Err("updates are handled by the browser: reload the page".into()); - } - "app.palette" => self.palette = Some((String::new(), 0)), - "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), - "app.openDialog" => self.pick_and_open(), - "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), - "app.smartArtPictureDialog" => { - if self.services.pick_open.is_none() { - return Err("picking a SmartArt picture needs the desktop app".into()); - } - self.pick_and_run("picture", "smartArt.picture", p); - } - "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), - "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), - "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), - "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), - "app.saveAsDialog" => self.save_as_dialog("deckcraft"), - "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), - "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), - "app.browseThemeDialog" => { - if self.session.active().is_none() { - return Err("open a presentation first".into()); - } - if self.services.pick_open.is_some() { - self.pick_and_insert("theme", "design.browseTheme"); - } else { - // The browser's asynchronous picker would open the file as a new document. - self.set_status("Browse for Themes needs the desktop app for now".to_string()); - } - } - "app.qat" => return qat::command(self, p), - "app.find" | "app.replace" if self.session.active().is_some() => { - let mut d = dialogs::Dialog::new("find"); - d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); - self.dialog = Some(d); - } - "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), - "app.compareDialog" => { - if self.session.active().is_none() { - return Err("open the presentation to compare first".into()); - } - if self.services.pick_open.is_some() { - self.pick_and_insert("open", "review.compare"); - } else { - // The browser's asynchronous picker would open the file as a new document. - self.set_status("Compare needs the desktop app for now".to_string()); - } - } - "app.dialog" => { - let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; - self.dialog = Some(dialogs::Dialog::new(d)); - } - "app.links" => { - use deckcraft_engine::links::*; - return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); - } - _ => return Err(format!("unknown UI command `{id}`")), - } - Ok(Value::Null) - } - - /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the - /// thumbnail pane has been clicked. - pub(crate) fn slides_list_focused(&self) -> bool { - self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) - } - - /// First slide Play from Start should open: the custom show or range, skipping hidden slides. - pub fn show_start_index(&self) -> usize { - self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) - } - - /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of - /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or - /// opens on the end screen. Hidden slides are allowed, as in "from current slide". - pub(crate) fn show_from(&self, p: &Value) -> Result, String> { - let from = match p.get("from") { - None | Some(Value::Null) => return Ok(None), - Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, - }; - let count = self.session.active().map_or(0, |d| d.doc.slides.len()); - if from >= count { - return Err(format!("no slide {from}")); - } - Ok(Some(from)) - } - - /// Leave animation preview, stop editor-owned media, and create a show at the requested slide - /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. - pub fn start_show(&mut self, from: usize, reading: bool) { - self.anim_preview = None; - self.media.stop_owned(media::Owner::Editor); - if let Some(d) = self.session.active() { - self.show_instance = self.show_instance.wrapping_add(1).max(1); - let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); - show.instance = self.show_instance; - self.show = Some(show); - } - } - - /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. - pub fn start_custom_show(&mut self, name: &str, reading: bool) { - let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); - let Some(state) = state else { return }; - let from = state.slide; - self.start_show(from, reading); - if let Some(show) = self.show.as_mut() { - show.state = state; - } - } - - /// The current slide's animations are playing on the editor canvas. - pub fn anim_preview_active(&self) -> bool { - self.anim_preview.is_some() - } - - /// `(slide, seconds played, sequence length)` while a preview is running. - pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { - self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) - } - - pub fn stop_anim_preview(&mut self) { - self.anim_preview = None; - } - - /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. - pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { - self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) - } - - /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence - /// (plus a short hold) has finished. - pub fn tick_anim_preview(&mut self, now: f64) -> bool { - let Some(p) = self.anim_preview.as_ref() else { return false }; - let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); - let alive = self.show.is_none() - && self.session.active().is_some_and(|d| { - d.uid == uid - && d.selection.target == deckcraft_engine::Target::Slides - && d.selection.slide == slide - && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) - }); - if !alive { - self.anim_preview = None; - return false; - } - let Some(p) = self.anim_preview.as_mut() else { return false }; - let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; - let start = *p.started.get_or_insert(now); - let elapsed = p.from + (now - start).max(0.0); - if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { - self.anim_preview = None; - return false; - } - p.elapsed = elapsed; - true - } - - /// Play the current slide's animations on the canvas, from effect `from` (index into the slide's - /// animations) or from the start. - fn start_anim_preview(&mut self, from: Option) -> Result { - let (uid, slide, ptr, timeline) = { - let Some(d) = self.session.active() else { - return Err("no presentation is open".into()); - }; - if d.selection.target != deckcraft_engine::Target::Slides { - return Err("animation preview plays the current slide".into()); - } - let slide = d.selection.slide; - let Some(slide_arc) = d.doc.slides.get(slide) else { - return Err("no slide to preview".into()); - }; - let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; - let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); - (d.uid, slide, ptr, timeline) - }; - if timeline.is_empty() { - self.anim_preview = None; - self.set_status("This slide has no animations"); - return Ok(json!({"playing": false})); - } - let from_secs = match from { - Some(i) => timeline.start_of(i).ok_or_else(|| format!("no effect {i} in this slide's click sequence"))?, - None => 0.0, - }; - if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { - self.ui.view = ViewMode::Normal; - } - self.close_backstage(); - let duration = timeline.total_duration(); - self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); - self.set_status("Previewing animations"); - Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) - } - - pub(crate) fn pick_and_open(&mut self) { - if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick("open") { - self.open_path_in_background(&path); - } - } else if self.services.open_async.is_some() { - let id = self.begin_external_file("open"); - if let Some(f) = self.services.open_async.as_mut() { - f("open", id); - } - } - } - - pub fn open_path(&mut self, path: &str) -> Result<(), String> { - self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; - self.note_recent(path); - Ok(()) - } - - /// Files the system hands over (the command line, a double-click through the file - /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide - /// show starts at once from where Play from Start begins, as PowerPoint does; anything else - /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that - /// couldn't be opened, with the reason. - pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { - let mut failed = Vec::new(); - let mut last = None; - for p in paths { - match self.open_path(p) { - Ok(()) => last = Some(p), - Err(e) => failed.push((p.clone(), e)), - } - } - if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { - self.start_show(self.show_start_index(), false); - } - failed - } - - /// A presentation was opened from `path`: first in the recent list, shown in Normal view. - pub(crate) fn note_recent(&mut self, path: &str) { - self.status = None; // an older error (a missing file just before this one) must not linger - self.recent_checked.remove(path); - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(RECENT_MAX); - self.ui.view = ViewMode::Normal; - } - - /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. - pub(crate) fn pick_transition_sound(&mut self) { - if self.services.pick_open.is_some() { - self.pick_and_insert("audio", "transition.sound"); - } - } - - /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the - /// current one (desktop only: the browser picker opens what it picks as a document). - fn pick_and_reuse(&mut self, keep: bool) { - let Some(pick) = self.services.pick_open.as_mut() else { - self.set_status("Reuse Slides needs the desktop app".to_string()); - return; - }; - let Some(path) = pick("open") else { return }; - // Read the file once: the list shown and the slides inserted come from the same bytes, even - // if the file changes while the dialog is open. Without a reader the path is used as is. - let mut source = json!({}); - match self.services.read.as_ref().map(|r| r(&path)) { - Some(Ok(bytes)) => { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - source["name"] = json!(name); - source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); - } - Some(Err(e)) => { - self.set_status(e.to_string()); - return; - } - None => source["path"] = json!(path), - } - // Offer the file's slides to choose from; all of them are ticked to begin with. - match self.session.execute("slide.reuseList", &source) { - Ok(list) => { - let mut d = dialogs::Dialog::new("reuseSlides"); - d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]}); - self.reuse_target = Some(self.capture_file_target()); - self.dialog = Some(d); - } - Err(e) => self.set_status(e.to_string()), - } - } - - /// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that - /// `slide.reuseList` listed) after the slide that was current when the picker opened, in that - /// presentation, even if another one is active now; if it is closed or the slide is gone, - /// nothing is inserted. Returns whether they were inserted. - pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool { - let mut args = if source.is_object() { source.clone() } else { json!({}) }; - args["keepFormatting"] = json!(keep); - args["slides"] = json!(picked); - let result = match self.reuse_target { - Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args), - _ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()), - }; - match result { - Ok(()) => { - self.reuse_target = None; - self.set_status(format!("Inserted {} slides", picked.len())); - true - } - Err(e) => { - self.set_status(e); - false - } - } - } - - pub(crate) fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { - self.pick_and_run(purpose, cmd, &json!({})); - } - - /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. - fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { - if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick(purpose) { - let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; - let r = match self.services.read.as_ref().map(|r| r(&path)) { - Some(Ok(bytes)) => { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - args["name"] = json!(name); - args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); - self.session.execute(cmd, &args) - } - _ => { - args["path"] = json!(path); - self.session.execute(cmd, &args) - } - }; - if let Err(e) = r { - self.set_status(e.to_string()); - } - } - } else if self.services.open_async.is_some() { - let id = self.begin_external_file(purpose); - // Change Picture: the picture that arrives replaces the selected one (#115). - if cmd == "picture.change" - && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) - { - r.purpose = FilePurpose::ChangePicture; - } - if let Some(f) = self.services.open_async.as_mut() { - f(purpose, id); - } - } - } - - pub fn save_as_dialog(&mut self, format: &str) { - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - let ext = if format == "pptx" { "pptx" } else { "deckcraft" }; - let suggested = format!("{name}.{ext}"); - if let Some(pick) = self.services.pick_save.as_mut() { - if let Some(path) = pick(&suggested) { - match self.session.execute("file.saveAs", &json!({"path": path})) { - Ok(_) => { - self.set_status(format!("Saved {path}")); - self.ui.recent.retain(|p| *p != path); - self.ui.recent.insert(0, path); - } - Err(e) => self.set_status(e.to_string()), - } - } - } else if let Some(dl) = self.services.download.as_mut() - && let Some(d) = self.session.active() - { - match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) { - Ok(bytes) => dl(&suggested, &bytes), - Err(e) => self.set_status(e.to_string()), - } - } - } - - /// Save every presentation with unsaved changes (asking for a location where needed). - /// Returns false if one wasn't saved (cancelled or failed). - pub fn save_all(&mut self) -> bool { - let dirty: Vec = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect(); - for i in dirty { - self.session.set_active(i); - self.save(); - if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) { - return false; - } - } - true - } - - pub fn save(&mut self) { - if self.session.active().and_then(|d| d.path.clone()).is_some() { - match self.session.execute("file.save", &json!({})) { - Ok(_) => self.set_status("Saved"), - Err(e) => self.set_status(e.to_string()), - } - } else { - self.save_as_dialog("deckcraft"); - } - } - - /// Act on requests commands left for the UI. - fn drain_requests(&mut self) { - for r in std::mem::take(&mut self.session.ui_requests) { - match r { - UiRequest::Dialog { id, params } => { - let mut d = dialogs::Dialog::new(&id); - d.params = params; - self.dialog = Some(d); - } - UiRequest::Pane { id } => self.ui.pane = Some(id), - UiRequest::StartShow { from } => self.start_show(from, false), - UiRequest::Message { text } => self.set_status(text), - UiRequest::PickFile { purpose } => { - if purpose == "saveAs" { - self.save_as_dialog("deckcraft"); - } - } - UiRequest::Media { action, shape, ms } => { - let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; - // The command resolved `shape` on the editor's slide, also while a show runs. - if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { - self.media.request(uid, &doc, slide, &action, shape, ms, owner); - } - } - } - } - } - - pub(crate) fn drain_inbox(&mut self) { - let Some(inbox) = self.services.inbox.clone() else { return }; - let events = match inbox.lock() { - Ok(mut v) => std::mem::take(&mut *v), - Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), - }; - for event in events { - match event { - InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), - InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), - } - } - } - - /// Remember the open presentation, the slide (or master/layout) and the operation for a - /// file the host will read asynchronously. Returns the cancellation identity the host must - /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the - /// bytes arrive. - pub fn begin_external_file(&mut self, purpose: &str) -> u64 { - let id = self.next_file_id; - self.next_file_id = self.next_file_id.wrapping_add(1).max(1); - self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); - id - } - - /// A dropped or picked file read synchronously: presentations open, media is inserted on - /// the slide that is current now. - pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { - let id = self.begin_external_file("drop"); - self.apply_external_file(id, name, bytes); - } - - fn capture_file_target(&self) -> FileTarget { - let Some(d) = self.session.active() else { return FileTarget::Absent }; - let place = match d.selection.target { - Target::Slides => match d.current_slide() { - Some(s) => FilePlace::Slide(s.id), - None => FilePlace::Unspecified, - }, - Target::Master { master } => match d.doc.masters.get(master) { - Some(m) => FilePlace::Master(m.id), - None => FilePlace::Unspecified, - }, - Target::Layout { master, layout } => match d.doc.masters.get(master) { - Some(m) => match m.layouts.get(layout) { - Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, - None => FilePlace::Unspecified, - }, - None => FilePlace::Unspecified, - }, - t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), - }; - FileTarget::Document { doc: d.uid, place } - } - - /// True when the user is still looking at the place this request captured. - fn viewing_target(&self, target: &FileTarget) -> bool { - let FileTarget::Document { doc, place } = target else { return false }; - let Some(d) = self.session.active() else { return false }; - if d.uid != *doc { - return false; - } - match place { - FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), - FilePlace::Master(id) => { - matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) - } - FilePlace::Layout { master, layout } => { - matches!(d.selection.target, Target::Layout { master: mi, layout: li } - if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) - } - FilePlace::NotesPage(t) => d.selection.target == *t, - FilePlace::Unspecified => false, - } - } - - fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { - let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; - let req = self.pending_files.remove(pos); - let cmd = match command_for(req.purpose, name, bytes) { - Ok(cmd) => cmd, - Err(e) => { - self.set_status(e); - return; - } - }; - let params = if cmd == "slide.fromOutline" { - json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) - } else { - json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) - }; - let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { - self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) - } else { - self.execute_on_target(&req.target, name, cmd, ¶ms) - }; - match result { - Err(e) if cmd == "file.openBytes" => self.notify(e), - Err(e) => self.set_status(e), - Ok(()) => {} - } - self.drain_requests(); - } - - /// Run `cmd` on the document and slide captured for the request, then put the view back - /// if the user has since moved. A missing document or slide cancels the file. - fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { - let FileTarget::Document { doc, place } = target else { - self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; - return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); - }; - let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { - return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); - }; - let view = self.session.active_index(); - let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); - self.session.set_active(idx); - let located = match self.locate_place(place) { - Ok(located) => located, - Err(e) => { - self.restore_file_view(view); - return Err(format!("{name} was not applied: {e}")); - } - }; - if let Some(st) = self.session.active_mut() { - st.selection.target = located.target; - if let Some(slide) = located.slide { - st.selection.slide = slide; - st.selection.slides.clear(); - } - st.selection.shapes.clear(); - st.selection.text = None; - st.selection.cells = None; - } - let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); - if view == Some(idx) { - if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { - st.selection = sel; - } - } else { - self.restore_file_view(view); - } - result - } - - fn restore_file_view(&mut self, view: Option) { - if let Some(i) = view - && i < self.session.documents().len() - { - self.session.set_active(i); - } - } - - fn locate_place(&self, place: &FilePlace) -> Result { - let d = self.session.doc().map_err(|e| e.to_string())?; - match place { - FilePlace::Slide(id) => { - let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) - } - FilePlace::Master(id) => { - let master = - d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Master { master }, slide: None }) - } - FilePlace::Layout { master, layout } => { - let mi = - d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; - let li = d - .doc - .masters - .get(mi) - .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) - .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) - } - FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), - FilePlace::Unspecified => Err("there was no slide to put it on".into()), - } - } - - /// Keep a hidden show playing from logic alone. `run_logic` does not refresh - /// `InputState::time`, so the show anchors its last visible time to this monotonic instant - /// and advances from there. A forced hidden paint draws the current state but does not - /// advance it a second time, and no paused-show product policy is invented. - pub(crate) fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { - let visible = show::viewport_visible(ctx); - let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; - let Some(show) = self.show.as_mut() else { return }; - if visible { - // A visible logic-only pass (`run_logic`) carries the last full frame's input time. - // Anchoring that stale time to `now` would move the hidden clock backwards if the - // window hides before the next full pass. `show::ui` already records the anchor on - // every visible full pass, where `InputState::time` is current. - return; - } - if !show.hidden_started() { - return; - } - let input_time = ctx.input(|i| i.time); - let show_time = show.hidden_logic_time(input_time, now); - let keep = show.advance_hidden(&doc, show_time); - let time_driven = show.hidden_time_driven(&doc); - if !keep { - self.show = None; - return; - } - if time_driven { - // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the - // logic clock ticking; with no paint request pending eframe services it as a - // logic-only pass, not a visible frame. - ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); - } - } - /// Background work, run on every logic call (visible, hidden or `run_logic`). /// /// Only consumed queues and monotonic deadlines live here: control requests, asynchronous @@ -1374,1209 +245,6 @@ impl SlideApp { } } - /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because - /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. - fn ui_setup(&mut self, ctx: &egui::Context) -> bool { - let scale = self.ui.ui_scale.clamp(0.5, 3.0); - if (ctx.zoom_factor() - scale).abs() > 1e-3 { - ctx.set_zoom_factor(scale); - } - if !self.styled { - theme::install_fonts_with(ctx, &self.ui_fonts.faces); - self.styled = true; - self.restyle = true; - // Font definitions take effect at the next pass boundary. Do not paint a language - // or symbol with the old font set while the new set is still pending. - ctx.request_discard("install interface fonts before layout"); - return false; - } - if self.restyle { - theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); - self.restyle = false; - } - self.fonts_ready = true; - true - } - - /// AutoRecover on a monotonic deadline, even when nothing is painted. - fn autosave_if_due(&mut self, ctx: &egui::Context) { - if self.session.recovery_dir.is_none() { - return; - } - let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); - let now = monotonic_secs(); - if now - self.last_recovery > every { - self.last_recovery = now; - if self.session.documents().iter().any(|d| d.is_dirty()) - && let Err(e) = self.session.execute("file.recovery.save", &json!({})) - { - self.set_status(format!("Couldn't save AutoRecover information: {e}")); - } - } - if self.session.documents().iter().any(|d| d.is_dirty()) { - let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); - ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); - } - } - - /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real - /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. - fn ui_end(&mut self, ctx: &egui::Context) { - self.ime_frame_viewport = None; - self.capture.observe_ui(ctx); - if !ctx.will_discard() { - let doc = self.session.active().map(|d| d.uid); - self.capture.issue(ctx, doc, web_time::Instant::now()); - } - if self.fonts_ready && !ctx.will_discard() { - self.synthetic_pending = false; - self.synthetic_injected.clear(); - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_focus = None; - if self.synthetic.is_empty() { - self.synthetic_target = None; - } - self.promote_next_synthetic(); - } - if self.fonts_ready && !ctx.will_discard() { - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; - } - } - - /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open - /// presentations' names and text, recent files, and what is typed or composed this frame. - fn note_ui_text(&mut self, ctx: &egui::Context) { - let typed: String = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), - egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), - _ => None, - }) - .collect() - }); - self.ui_fonts.note(ctx, &typed); - for d in self.session.documents() { - self.ui_fonts.note(ctx, &d.title()); - } - for r in &self.ui.recent { - self.ui_fonts.note(ctx, r); - } - if let Some(st) = self.session.active() { - self.ui_fonts.note_doc(ctx, st); - } - self.ui_fonts.apply(ctx); - } - - /// Stable IME owner for the canvas text target: document, view, shape and table cell. - /// - /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary - /// edits and caret movement keep the same owner. A new document, shape or cell is a new - /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key - /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is - /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. - pub(crate) fn ime_target(&self) -> Option { - let st = self.session.active()?; - let (shape, cell, notes) = match &st.selection.text { - Some(t) => (t.shape, t.cell, t.notes), - None => (*st.selection.shapes.first()?, None, false), - }; - if st.selection.text.is_none() && st.selection.shapes.len() != 1 { - return None; - } - let target = match st.selection.target { - Target::Slides => (0u8, 0usize, 0usize), - Target::Master { master } => (1, master, 0), - Target::Layout { master, layout } => (2, master, layout), - Target::Handout => (3, 0, 0), - Target::NotesMaster => (4, 0, 0), - }; - let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); - Some(egui::ime_context::ImeTarget::new(owner, 0)) - } - - /// The committed surrounding snapshot for the current edit target, or `Unavailable` when - /// there is an owner but no committed text to describe. - /// - /// An unavailable stamp carries the document revision. The tracker requires a strictly - /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a - /// wide selection or a commit that starts from a selected shape would poison the whole frame. - fn ime_receipt_stamp(&self) -> Option { - let st = self.session.active()?; - self.ime_target()?; - Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( - || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), - |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), - )) - } - - /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any - /// earlier or later IME receipt in the same frame. - fn note_application_edit(&mut self, ctx: &egui::Context) { - let Some(viewport) = self.ime_frame_viewport else { return }; - if let Some(target) = self.ime_target() { - let frame = ctx.cumulative_frame_nr_for(viewport); - self.ime_tracker.note_application_change(viewport, frame, target); - } - } - - /// Record the expected selection after an accepted IME event. Its own deletion may move - /// the caret/revision; that is not an application interruption of the following commit. - fn refresh_ime_terminal_selection(&mut self) { - let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); - if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { - pending.revision = revision; - pending.anchor = anchor; - pending.caret = caret; - } - } - - /// The current live destination for an IME reset fence. - fn ime_destination(&self) -> Option { - let st = self.session.active()?; - let target = self.ime_target()?; - let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); - Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) - } - - /// Use the recorded IME viewport only while it matches the current context viewport. - fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { - self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) - } - - /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to - /// another viewport's phase. - pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { - self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) - } - - /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. - pub(crate) fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { - if matches!(phase, ImeResetPhase::Clear) { - let _ = self.ime_reset.remove(&viewport); - } else { - self.ime_reset.insert(viewport, phase); - } - } - - /// The live stamp used to compare against the origin/interrupted terminal destination. - fn ime_terminal_stamp(&self) -> Option { - let st = self.session.active()?; - let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); - Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) - } - - /// Positive route predicate for the canvas IME producer: this pass must be one that can - /// actually paint `canvas::show` for the active document, with no modal, stock field or - /// overlay owning input. - /// - /// Sorter and Notes Page are editor views without a canvas producer, so they are named here - /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or - /// closing window, a missing document/target, a focused stock field, modal dialog, palette, - /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the - /// authority for this pass's actual descriptor; keeping this predicate positive means a view - /// added later is not admitted by omission simply because it does not call `canvas::show`. - pub(crate) fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { - matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) - && self.session.active().is_some() - && self.ime_target().is_some() - && ctx.input(|i| i.viewport().focused.unwrap_or(true)) - && ctx.input(|i| i.viewport().visible() != Some(false)) - && !ctx.input(|i| i.viewport().close_requested()) - && ctx.memory(|m| m.focused().is_none()) - && self.dialog.as_ref().is_none_or(|d| !d.modal()) - && self.palette.is_none() - && self.backstage.is_none() - && self.show.is_none() - && self.opening.is_none() - } - - /// The singleton app's session, composition tracker and terminal guard belong to its owning - /// viewport. View › New Window and Presenter View render through their own immediate - /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only - /// viewport mutate root composition state while only the reset map is per-viewport. Reject it - /// before any state changes instead of pretending to support multi-view editing. - fn root_viewport_admitted(ctx: &egui::Context) -> bool { - ctx.viewport_id() == egui::ViewportId::ROOT - } - - /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than - /// standalone logic. - fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { - let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); - ctx.data(|d| d.get_temp::(key).unwrap_or(false)) - } - - /// A new application-origin interruption supersedes any previous pending fence. - /// - /// This runs from the logic half of a full pass and from raw input handling, before the - /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: - /// putting a token into egui `Memory` now would let egui attach it to a later stock - /// descriptor, or to a canvas this route does not paint. Retire the superseded id - /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may - /// allocate its one replacement after confirming an eligible custom output. - pub(crate) fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { - let viewport = self.ime_viewport_or(ctx); - if !fresh - && matches!( - self.ime_reset_for(viewport), - ImeResetPhase::Awaiting { viewport: guard_viewport, .. } - | ImeResetPhase::Suspended { viewport: guard_viewport, .. } - if guard_viewport == viewport - ) - { - // This viewport already has a live or suspended guard. The finalizer replaces it - // once if the actual destination changed; do not churn a request per event. - return; - } - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - - /// Custom admission was lost on this route. Retire the old id without inventing an - /// acknowledgement and block until a later eligible pass can request a replacement. - fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { - if self.ime_reset_for(viewport).blocks_custom_input() { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - - /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are - /// inert; this never mutates text, caret or undo history. - fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { - let viewport = self.ime_viewport_or(ctx); - if request_id.viewport_id() != viewport { - return; - } - if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) - && id == request_id - && waiting_viewport == viewport - { - self.set_ime_reset(viewport, ImeResetPhase::Clear); - // The exact ordered completion is the platform-reset boundary. It also releases a - // cleared-preedit terminal guard: the app no longer has to wait for the old - // Commit/Enabled/Disabled before a fresh composition may start. Events ordered - // before this marker remain rejected; a late old terminal after the reset has the - // same documented residual as any other event crossing a platform reset. - if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { - self.ime_terminal_pending = None; - } - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - } - - /// Controls-only routing for a route where the custom editor cannot mutate. Markers are - /// visited in raw order; stock text/key events stay untouched for the stock widgets. - fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { - for e in events { - if let egui::Event::ImeResetCompleted { request_id } = e { - self.ime_reset_completed(*request_id, ctx); - } - } - } - - /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. - pub(crate) fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { - let viewport = self.ime_viewport_or(ctx); - let phase = self.ime_reset_for(viewport); - let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); - let destination = self.ime_destination(); - // The finalizer is the allocation authority, so it rechecks the positive canvas route - // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, - // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout - // discard or a missing target must suspend without putting a token into egui Memory. - let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); - let custom_output = match (eligible, custom, actual, destination) { - (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), - _ => None, - }; - let Some((output_context, destination)) = custom_output else { - match phase { - ImeResetPhase::Clear => {} - ImeResetPhase::Awaiting { .. } => { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { - // A request that was not bound to the actual custom output in this pass is - // retired locally; a later eligible activation obtains a fresh one. A guard - // stamped for another viewport (or a request issued for one) is never reused. - if guard_viewport != viewport || request.is_some() { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - ImeResetPhase::AllocationFailed { .. } => { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - return; - }; - match phase { - ImeResetPhase::Clear => {} - ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { - if old_viewport != viewport || old_destination != destination || old_context != output_context { - self.request_ime_rearm(ctx, viewport, destination, output_context); - } - } - ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { - let request = if needs_rearm && guard_viewport == viewport { - // Only a request issued for this exact viewport may be bound here. - request.filter(|id| id.viewport_id() == viewport) - } else { - None - }; - match request.or_else(|| ctx.request_ime_reset(viewport)) { - Some(id) => { - self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); - } - None => { - self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); - } - } - } - ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { - // Repeated idle output for the same destination does not retry allocation. - if old_viewport != viewport || old_destination != destination || old_context != output_context { - self.request_ime_rearm(ctx, viewport, destination, output_context); - } - } - } - } - - /// Request one replacement IME reset token and record `Awaiting` on success or - /// `AllocationFailed` when no token can be allocated. - fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { - match ctx.request_ime_reset(viewport) { - Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), - None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), - } - } - - /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying - /// stale input; the next full UI pass can rearm from its actual descriptor. - fn finalize_ime_logic(&mut self, ctx: &egui::Context) { - let viewport = self.ime_viewport_or(ctx); - if self.ime_reset_for(viewport).blocks_custom_input() { - // Only this viewport's guard is touched. A root/child logic tick must not suspend or - // rebind another viewport's independent reset lifetime. - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - - /// Note compositions the application settled since the last check (a click, a key, another - /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], - /// and a platform event for them is stale from that moment on, even in the same pass. - pub(crate) fn note_settled_composition(&mut self, ctx: &egui::Context) { - let settles = self.session.composition_settles(); - let current_stamp = self.ime_terminal_stamp(); - // `file.close` then `file.new` can replace the active uid before any pass observes the - // empty document. That is not a safe boundary for the old terminal: the platform may - // still deliver its suffix. Keep the guard bound to its retired owner and treat the new - // destination as a fresh interruption; only exact reset completion or an observed - // teardown may clear the terminal metadata. - let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { - let reference = if p.interrupted { - p.interrupted_at - } else { - Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) - }; - reference != current_stamp - }); - if settles == self.ime_settles_seen && !pending_interrupted { - return; - } - let fresh = settles != self.ime_settles_seen; - self.ime_settles_seen = settles; - if let Some(pending) = &mut self.ime_terminal_pending { - pending.cancelled = true; - pending.interrupted = true; - // Compare later passes against the destination where interruption actually happened, - // not forever against the retired composition's origin. The destination may be absent - // (the interruption closed its target); `interrupted` keeps that distinct from "not - // interrupted yet". - pending.interrupted_at = current_stamp; - } - self.ime_interrupt_pending = true; - self.note_ime_interruption(ctx, fresh); - let cancelled = self.ime_composition.as_mut().map(|c| { - c.settled = true; - (c.owner, c.generation) - }); - if let Some((owner, generation)) = cancelled { - // Retain a tombstone beyond the first stale event: clearing the composition must - // not let the rest of this input batch commit into the next target. `observe_ime` - // retires it in the pass that publishes the matching interrupt. - self.ime_cancelled = Some(ImeCancelled { owner, generation }); - } - } - - /// An input method's event for the slide's text (#101): the preedit is shown in the text and - /// replaced on each update; the commit replaces it as one undo step. - /// - /// An event only ever changes the target its composition began in. The application settles a - /// composition itself on a click, a key, a command or a target change, and the platform may - /// still hold it: pre-edits and commits queued for that generation in the settling input batch - /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is - /// selected now. After the interrupt has been published the platform is reset, so the next - /// batch is treated as new input; egui's event payload has no generation, so an OS-queued - /// event that crosses that reset cannot be distinguished from a legitimate one. - /// - /// A receipt is captured immediately before and after a successful commit or - /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn - /// [`egui::output::ImeChangeCause::InputMethod`]. - pub(crate) fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { - self.note_settled_composition(ctx); - let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); - let frame = ctx.cumulative_frame_nr_for(viewport); - let target = self.ime_target(); - let owner = target.map(|t| t.owner); - let accepted = - matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); - if self.reject_stale_ime_event(ctx, e, owner) { - return; - } - // Until the exact newest reset request completes at its ordered stream position, custom - // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do - // not release this guard; they still settle the old composition below. - #[allow(deprecated)] - if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { - return; - } - let before = if accepted { self.ime_receipt_stamp() } else { None }; - let r = self.apply_ime_event(e); - self.settle_ime_event_state(e, owner, r.is_ok()); - if r.is_ok() && accepted { - self.refresh_ime_terminal_selection(); - } - if let Err(e) = &r { - self.set_status(e.to_string()); - } else if accepted - && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) - && before != after - && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) - { - // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is - // not a mutation and must not poison the frame for a real commit later in the batch. - self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); - } - } - - /// Drop an event that belongs to a composition the application already settled, or to another - /// owner; returns `true` when the event must not be applied. - /// - /// A commit or deletion belongs to the composition that began the pre-edit: another owner - /// (the user clicked elsewhere) or a composition the app already settled makes it stale. - /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has - /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass - /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the - /// backend just the same), so a later commit-only is not lost forever: it is a - /// legitimate direct commit or a new platform composition. egui's events carry no - /// generation, so an event queued by the OS across that reset is indistinguishable from - /// one the user just typed; only events in the same input batch as the settle are rejected - /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner - /// remains guarded across that output boundary until its actual commit/enable/disable - /// arrives. Publishing a reset is not evidence that the separately queued terminal was - /// delivered. With no incoming event epoch, the first ambiguous new composition after an - /// unobserved reset may be rejected; no timer guesses that it became safe. - /// The tombstone is not filtered by the *current* target: a click can change the owner in - /// the settling pass, and every remaining event of that batch still belongs to the - /// pre-reset composition. Filtering would let the event after the first dropped pre-edit - /// start composing in the new target before the platform is reset. - fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { - let cancelled = self.ime_cancelled; - let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); - #[allow(deprecated)] - let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) - && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); - if !stale { - return false; - } - if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { - // The old terminal finally arrived: reject it and release its pending owner now, - // so a new preedit later in this same raw batch can belong to the current target. - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - return true; - } - if pending_cancelled { - // note_settled_composition already queued the reset. Do not also latch the - // frame's memory interrupt: a terminal later in this batch can consume it and - // permit a fresh preedit without resetting that new composition at pass end. - self.ime_composition = None; - return true; - } - if let Some(c) = cancelled { - log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); - } - self.ime_composition = None; - // Ask the backend to drop its copy, so this is one stale event and not a stream. - ctx.memory_mut(|m| m.interrupt_ime()); - true - } - - /// Apply the document mutation an IME event asks for, before any receipt or tracker state. - fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { - match e { - egui::ImeEvent::Preedit { text, active_range_chars } => { - self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) - } - // A commit only replaces text that is being edited. It must never start editing a - // merely selected shape: without a tracked pre-edit, a stale platform commit would - // replace that shape's whole text. - egui::ImeEvent::Commit(text) => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.commit_text(text) - } else { - Ok(Value::Null) - } - } - egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.delete_surrounding(*before_chars, *after_chars, false) - } else { - Ok(Value::Null) - } - } - egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.delete_surrounding(*before_bytes, *after_bytes, true) - } else { - Ok(Value::Null) - } - } - #[allow(deprecated)] - egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { - // The platform ended (or is about to restart) its composition itself. - self.session.end_composition(); - Ok(Value::Null) - } - } - } - - /// Update the tracked composition after `e` has been applied; `applied` says whether the - /// document mutation succeeded. - fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { - match e { - // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is - // left to interrupt. A pre-edit keeps (or starts) the tracked composition. - egui::ImeEvent::Commit(_) => { - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - #[allow(deprecated)] - egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { - // A platform-side disable/enable already reset the composition. Do not carry an - // older app-settled interrupt into the next (possibly new) composition. - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - egui::ImeEvent::Preedit { text, .. } => { - match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { - (Some(owner), true, true) => { - // Updating the same composition keeps its generation. After a settle (or - // a first pre-edit) this is a new generation and clears the tombstone. - let generation = match self.ime_composition { - Some(c) if c.owner == owner && !c.settled => c.generation, - _ => { - self.ime_generation = self.ime_generation.wrapping_add(1).max(1); - self.ime_generation - } - }; - self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); - self.ime_terminal_pending = None; - self.ime_cancelled = None; - } - (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { - // The model restored committed text, but no terminal was delivered. Keep - // this owner across passes; a later pointer/key/target change must reject - // its separately queued old delete/commit, even after an outgoing reset. - if let Some(st) = self.session.active() - && let Some(t) = &st.selection.text - { - self.ime_terminal_pending = Some(ImeTerminalPending { - owner, - revision: st.revision, - anchor: t.anchor, - caret: t.caret, - cancelled: false, - interrupted: false, - interrupted_at: None, - }); - } - } - _ => { - self.ime_composition = None; - self.ime_terminal_pending = None; - } - } - } - _ => {} - } - } - - /// Observe the canvas IME owner once per pass and publish its descriptor. - /// - /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, - /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means - /// the custom owner is hidden: the tracker is told so and the next focus starts a new - /// generation. The descriptor is rebuilt from the current committed text every pass. - /// - /// A composition the application settled itself is announced once as - /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That - /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit - /// terminal retains its separate pending owner until the incoming terminal is observed. - pub(crate) fn observe_ime(&mut self, ctx: &egui::Context) -> Option { - let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); - self.note_settled_composition(ctx); - // The editing window going away really ends the viewport's context; a merely hidden - // owner (modal, palette, unfocused field) stays observed as unfocused instead. - if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { - self.ime_interrupt_pending = false; - if let Some(old) = self.ime_viewport.take() { - self.ime_tracker.end_viewport(old); - // Explicit viewport destruction removes only that viewport's guard. - let _ = self.ime_reset.remove(&old); - } - let _ = self.ime_reset.remove(&viewport); - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_terminal_pending = None; - return None; - } - if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { - // The native window itself lost focus: no custom editor is eligible this pass, even - // if the model still has a text selection and a last-pass geometry rect. - self.ime_tracker.observe(viewport, None); - return None; - } - let Some(rect) = self.ime_pending.take() else { - // No canvas editor was painted in this pass. A discarded font/layout pass publishes - // nothing the platform sees, so keep the interrupt and tombstone for a later pass. - // An accepted pass is a backend reset only when it publishes *no* IME descriptor at - // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its - // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate - // retirement on the actual committed output, not on the absence of the canvas rect; - // otherwise the next legitimate commit in the focused field would be rejected. - let no_descriptor = ctx.output(|o| o.ime.is_none()); - if !ctx.will_discard() && no_descriptor { - self.ime_interrupt_pending = false; - self.ime_cancelled = None; - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - } - self.ime_tracker.observe(viewport, None); - return None; - }; - if !self.ime_custom_canvas_route(ctx) { - // The canvas can still paint behind an opening overlay or a Sorter/Notes Page - // reroute, and a forced hidden paint can run after the window lost focus or - // visibility. The positive route and the UI wrapper both reject those, so a pending - // rect must not republish the custom owner. - self.ime_tracker.observe(viewport, None); - return None; - } - let Some(st) = self.session.active() else { - self.ime_interrupt_pending = false; - self.ime_tracker.observe(viewport, None); - return None; - }; - let Some(target) = self.ime_target() else { - self.ime_interrupt_pending = false; - self.ime_cancelled = None; - self.ime_tracker.observe(viewport, None); - return None; - }; - if self.ime_viewport != Some(viewport) - && let Some(old) = self.ime_viewport.replace(viewport) - { - self.ime_tracker.end_viewport(old); - let _ = self.ime_reset.remove(&old); - } - let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); - let mut observation = - egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) - .purpose(egui::IMEPurpose::Normal) - .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); - if committed.is_none() { - observation = observation.unavailable_revision(st.revision); - } - let update = self.ime_tracker.observe(viewport, Some(observation))?; - // Clear the interrupt only once it is part of a settled pass's output descriptor. A - // discarded font/layout pass publishes nothing the platform sees, so keep it pending; - // that pass is not the boundary at which stale pre-edits become a new generation. - let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; - if interrupt { - // This pass's output carries the backend reset. The tombstone did its job for the - // events already processed in this batch; from the next batch on every pre-edit or - // commit is a new platform generation. A late event the OS queued across the reset - // has no generation in its payload, so it cannot be rejected without also dropping - // legitimate direct commits (documented residual in `ime_event`). - self.ime_cancelled = None; - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - } - let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); - ctx.output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - owner: update.owner, - context_generation: update.context_generation, - purpose: update.purpose, - rect, - cursor_rect: rect, - should_interrupt_composition: interrupt, - reset_request: None, - change_cause: update.change_cause, - surrounding_text: update.surrounding_text, - }); - }); - Some(key) - } - - /// Forget the injected or queued synthetic batch and everything recorded about it. Events - /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. - fn clear_synthetic_batch(&mut self) { - self.synthetic.clear(); - self.synthetic_pending = false; - self.synthetic_injected.clear(); - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_target = None; - self.synthetic_focus = None; - } - - /// Start the batch that was queued behind an older one once the older batch is gone. - fn promote_next_synthetic(&mut self) { - if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { - return; - } - self.synthetic = std::mem::take(&mut self.synthetic_next); - self.synthetic_target = self.synthetic_next_target.take(); - self.synthetic_focus = None; - } - - /// A font/layout pass will be discarded: egui already consumed the injected events but the - /// app deliberately lays nothing out, so put them back at the head of the queue. The next - /// frame injects them again after the new fonts are installed; otherwise a control click that - /// happened to arrive on the very first frame would be acknowledged without ever reaching the - /// UI. - fn requeue_injected_synthetic(&mut self) { - if !self.synthetic_pending { - return; - } - let mut combined = std::mem::take(&mut self.synthetic_injected); - combined.append(&mut self.synthetic); - self.synthetic = combined; - self.synthetic_pending = false; - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_focus = None; - if self.synthetic.is_empty() { - self.synthetic_target = None; - self.promote_next_synthetic(); - } - } - - /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried - /// first so an identical host event earlier in the batch is never removed; the last exact - /// match is only a fallback for a raw input that was rewritten around the events. - fn drain_synthetic_from(&self, events: &mut Vec) -> bool { - let injected = &self.synthetic_injected; - let n = injected.len(); - if n == 0 { - return false; - } - let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); - let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); - if let Some(at) = at { - events.drain(at..at + n); - true - } else { - false - } - } - - /// Drop an injected batch whose document, canvas, focus or modal owner changed during - /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained - /// there can still retarget the same `RawInput`. - /// - /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run - /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or - /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* - /// pass; remove the batch only when the live input carries exactly what this pass injected - /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending - /// bookkeeping so the next `raw_input_hook` can retract the events from the carried - /// `RawInput` before eframe delivers them in a full pass. - fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { - if !self.synthetic_pending { - return; - } - let current = self.input_target(); - let focus = ctx.memory(|m| m.focused()); - if self.synthetic_target == Some(current) && focus == self.synthetic_focus { - return; - } - let injected_time = self.synthetic_injected_time; - let mut removed = false; - ctx.input_mut(|i| { - // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the - // time of the RawInput this batch was injected into. Never drain an older batch that - // happens to look similar. - if i.raw.time != injected_time { - return; - } - let mut events = std::mem::take(&mut i.events); - let ok = self.drain_synthetic_from(&mut events); - i.events = events; - if ok { - // Keep `InputState::raw` consistent with what UI code reads as `events`. - let mut raw_events = std::mem::take(&mut i.raw.events); - let _ = self.drain_synthetic_from(&mut raw_events); - i.raw.events = raw_events; - removed = true; - } - }); - if removed { - self.clear_synthetic_batch(); - self.promote_next_synthetic(); - } else { - // Logic-only (or a pass whose input does not contain the batch): the events are in - // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the - // stale target so the next hook retracts them there. Events queued after the batch - // belong to the same stale target as well. - self.synthetic.clear(); - } - } - - /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is - /// the full pass the batch was admitted for and egui has already derived pointer/key state - /// from it. A logic-only callback leaves the previous pass's `InputState` (with another - /// `raw.time`) and must not be mistaken for the live pass, so control/background work is - /// still drained there and the next `raw_input_hook` retracts a batch that went stale. - fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { - if !self.synthetic_pending || self.synthetic_injected.is_empty() { - return false; - } - let Some(injected_time) = self.synthetic_injected_time else { return false }; - let injected = &self.synthetic_injected; - ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) - } - - /// Input that belongs to the pass about to run: native dropped files and one synthetic - /// control-channel batch. The synthetic batch is injected at most once; while it is - /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change - /// drops the whole queued batch rather than typing into the wrong place. - /// - /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will - /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe - /// carries the whole `RawInput` into the next full pass, so an already-injected batch is - /// retracted at the next hook if a control request changed the target, and otherwise is - /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase - /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - #[cfg(not(target_arch = "wasm32"))] - for f in std::mem::take(&mut raw.dropped_files) { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); - match f.bytes() { - Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), - Err(e) => self.set_status(e), - } - } - let focus = ctx.memory(|m| m.focused()); - if self.synthetic_pending { - let current = self.input_target(); - if self.synthetic_target != Some(current) || focus != self.synthetic_focus { - // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` - // and appends the next host input to it. The events are already in `raw.events`; - // clearing only the unsent queue would still deliver the stale click or key into - // the new document on the next full pass. Retract exactly the batch we appended. - let _ = self.drain_synthetic_from(&mut raw.events); - self.clear_synthetic_batch(); - // New control input queued for the current target while the stale batch was in - // flight is a separate batch; keep it instead of clearing it with the old one. - self.promote_next_synthetic(); - } else { - // This RawInput is the carrier for the next pass, and eframe refreshes its time - // on every call. Record the current time so `retract_synthetic_after_logic` can - // tell this full pass's live `InputState` from a logic-only callback's stale one. - self.synthetic_injected_time = raw.time; - } - } - if self.synthetic_pending || self.synthetic.is_empty() { - return; - } - let current = self.input_target(); - if self.synthetic_target != Some(current) { - // The batch was queued for a document/slide/canvas that has since changed. - self.synthetic.clear(); - self.synthetic_target = None; - self.promote_next_synthetic(); - if self.synthetic.is_empty() { - return; - } - if self.synthetic_target != Some(current) { - // The newer batch is stale too: drop it rather than delivering it to this target. - self.synthetic.clear(); - self.synthetic_target = None; - self.synthetic_next.clear(); - self.synthetic_next_target = None; - return; - } - } - self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); - log::debug!("injecting synthetic input batch {}", self.synthetic_generation); - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - let n = n.min(self.synthetic.len()); - let batch: Vec = self.synthetic.drain(..n).collect(); - let at = raw.events.len(); - raw.events.extend(batch.iter().cloned()); - self.synthetic_injected = batch; - self.synthetic_injected_at = Some(at); - self.synthetic_injected_time = raw.time; - self.synthetic_pending = true; - self.synthetic_focus = focus; - } - - /// The app shortcuts that still make sense on the empty File/start screen: create, open, - /// the command palette, preferences and the File tab itself. Document commands (save, find, - /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. - fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { - use egui::Key::*; - matches!( - (key, m.cmd, m.shift, m.alt), - (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) - ) - } - - /// File-tab navigation and document-free shortcuts, also used on the start screen. - fn route_backstage_keys(&mut self, events: Vec) { - if self.backstage.is_none() { - return; - } - // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app - // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the - // hidden presentation. - let start_screen = self.session.active().is_none(); - for e in events { - if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - let handled = backstage::key(self, key, mods) - // With no presentation the File home only owns the shortcuts that create or - // open one. Borrowing the whole document shortcut table here would also arm - // Save/Save As, hyperlink, find/replace, show and zoom against the empty - // start screen, which is a scope expansion no editing route needs. - || (mods.cmd || mods.alt) - && (!start_screen || Self::start_screen_shortcut(key, mods)) - && menus::ui_shortcut(self, key, mods); - if handled { - continue; - } - // Engine shortcuts that don't touch the hidden slide (Ctrl+N). - if mods.cmd - && matches!(key, egui::Key::N) - && let Err(e) = self.session.key(key_name(key), mods) - { - self.set_status(e.to_string()); - } - } - } - } - - /// Keyboard and clipboard for the slide (when no text field has focus). - fn keyboard(&mut self, ctx: &egui::Context) { - let events = ctx.input(|i| i.events.clone()); - // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the - // key press, so the release of a V press never seen is the paste. Tracked even while a text - // field has focus, so that a V typed there is not taken for one. - let mut image_paste = false; - let mut pasted_image_event = false; - for e in &events { - match e { - egui::Event::PasteImage(_) => { - pasted_image_event = true; - self.v_seen = true; - } - egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, - egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { - // Only a release with Command/Ctrl held is a paste: a bare V release is not. - let seen = std::mem::take(&mut self.v_seen); - image_paste = !seen && (modifiers.command || modifiers.ctrl); - } - _ => {} - } - } - let viewport = self.ime_viewport_or(ctx); - if self.session.active().is_none() { - // No document: keep slide-editing input suspended, while the start screen's - // File-tab navigation and document-free shortcuts remain available. - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { - self.route_backstage_keys(events); - } - self.drain_requests(); - return; - } - let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); - // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow - // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon - // buttons), and that stray focus would swallow every later key and end text entry. Let it - // go; a real text field or an open menu keeps its keys. - if !modal - && self.session.active().is_some_and(|d| d.selection.text.is_some()) - && !ctx.text_edit_focused() - && !ctx.any_popup_open() - && let Some(id) = ctx.memory(|m| m.focused()) - { - ctx.memory_mut(|m| m.surrender_focus(id)); - } - if ctx.memory(|m| m.focused().is_some()) || modal { - // A stock field/modal/palette owns input now. Retire the custom fence, then visit - // controls in raw order; stock text/key events are left for the stock widgets. - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - return; - } - if self.backstage.is_some() { - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - self.route_backstage_keys(events); - self.drain_requests(); - return; - } - let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; - if let Some(png) = img { - let data = deckcraft_engine::cmd::base64_encode(&png); - if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { - self.set_status(e.to_string()); - } - } - for e in events { - // An earlier command in this raw batch may have settled the composition. Request the - // replacement at this ordered boundary, before the current event is considered. - self.note_settled_composition(ctx); - match e { - egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), - egui::Event::PasteImage(image) => match color_image_png(&image) { - Ok(png) => { - let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); - if let Err(e) = self.session.execute("insert.picture", ¶ms) { - self.set_status(e.to_string()); - } - } - Err(e) => self.set_status(e), - }, - egui::Event::Text(t) => { - let revision = self.session.active().map(|d| d.revision); - if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) - && let Err(e) = self.session.type_text(&t) - { - self.set_status(e.to_string()); - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - egui::Event::Ime(ime) => self.ime_event(ctx, &ime), - egui::Event::Copy => self.publish_clipboard(ctx, false), - egui::Event::Cut => self.publish_clipboard(ctx, true), - egui::Event::Paste(text) => { - // Our own marker for objects without text: paste what the session copied. - let text = if text == OBJECTS_MARKER { String::new() } else { text }; - // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. - // Text keeps its HTML (bold, italic, underline) when the host can read it. - let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); - // egui 0.37 sends `PasteImage` directly; the services fallback stays for - // integrations that still send an empty `Paste`. Never insert twice. - let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; - let revision = self.session.active().map(|d| d.revision); - let r = match img { - Some(png) => self - .session - .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => { - let mut params = json!({"text": text}); - if let Some(h) = html { - params["html"] = Value::String(h); - } - self.session.execute("edit.paste", ¶ms) - } - }; - if let Err(e) = r { - self.set_status(e.to_string()); - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - egui::Event::Key { key, pressed: true, modifiers, .. } => { - if key == egui::Key::Tab { - // The slide has the keyboard, so Tab is ours (indent, tab character, next - // shape). Left to egui it would also focus the first ribbon widget, and - // every key after it would go there instead of the slide (#78). - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - } - if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { - self.anim_preview = None; - continue; - } - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { - continue; - } - let name = key_name(key); - let revision = self.session.active().map(|d| d.revision); - match self.session.key(name, mods) { - Ok(_) => {} - Err(e) => self.set_status(e.to_string()), - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - _ => {} - } - } - self.drain_requests(); - } - - /// Offer the copy or cut on the system clipboard. - /// - /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste - /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the - /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard - /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). - fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { - let cmd = if cut { "edit.cut" } else { "edit.copy" }; - let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; - let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); - let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) - { - return; - } - ctx.copy_text(system_clipboard_text(&text)); - } - /// Lay out the whole window. /// /// Every return route, including fonts/layout discards, show mode, modal/Backstage and the @@ -2684,150 +352,7 @@ impl SlideApp { self.ui_end(&ctx); } - /// Media clocks. Voices belong to the open presentation: closing the last one, or switching - /// decks, stops them. The editor's media also stops when its slide is left. This runs from - /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. - fn tick_media_clock(&mut self, ctx: &egui::Context) { - let uid = self.session.active().map(|d| d.uid); - self.media.set_document(uid); - if !self.media.any_active() { - self.session.media_status.clear(); - return; - } - if self.show.is_none() - && let Some(d) = self.session.active() - { - let here: Vec = if d.selection.target == deckcraft_engine::Target::Slides { - media::slide_media(&d.doc, d.selection.slide).into_iter().map(|m| m.0).collect() - } else { - vec![] - }; - let gone: Vec<_> = - self.media.ids().into_iter().filter(|id| !here.contains(id) && self.media.owner(*id) == Some(media::Owner::Editor)).collect(); - for id in gone { - self.media.stop(id); - } - } - let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); - if playing { - ctx.request_repaint_after(std::time::Duration::from_millis(15)); - } - } - pub fn open_url(&mut self, url: &str) { self.pending_urls.push(url.to_string()); } - - /// Drain pending control requests, executing ordinary requests and admitting screenshots for - /// later saving and reply. - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { - // A request whose sender gave up waiting (reply timeout) must not run late (#719). - if !req.take() { - continue; - } - let reply = req.reply.clone(); - let revision = self.session.active().map(|d| d.revision); - match control::handle(self, ctx, &req) { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { path } => { - let doc = self.session.active().map(|d| d.uid); - if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { - let _ = reply.send(json!({"ok": false, "error": e})); - } - } - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - self.control_rx = Some(rx); - } - - /// Save completed screenshots on the app thread and send exactly one reply each. - fn drain_screenshots(&mut self) { - let doc = self.session.active().map(|d| d.uid); - let completed = self.capture.drain(web_time::Instant::now(), doc); - for c in completed { - let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); - // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. - self.capture.release(c.reserved); - let _ = c.reply.send(v); - } - } -} - -/// Engine key name for an egui key. -pub fn key_name(key: egui::Key) -> &'static str { - use egui::Key::*; - match key { - Enter => "Enter", - Escape => "Escape", - Backspace => "Backspace", - Delete => "Delete", - Tab => "Tab", - ArrowLeft => "Left", - ArrowRight => "Right", - ArrowUp => "Up", - ArrowDown => "Down", - Home => "Home", - End => "End", - PageUp => "PageUp", - PageDown => "PageDown", - Space => "Space", - F2 => "F2", - F5 => "F5", - F7 => "F7", - other => other.name(), - } -} - -/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as -/// background removal. The image was already allocated by the integration; this keeps the -/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. -const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; - -/// Encode an egui color image as the PNG bytes `insert.picture` stores. -fn color_image_png(image: &egui::ColorImage) -> Result, String> { - let [w, h] = image.size; - let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; - if pixels == 0 { - return Err("the pasted image is empty".into()); - } - if pixels > MAX_PASTE_IMAGE_PIXELS { - return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); - } - let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); - Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) -} - -/// Images for the browser download: the current slide, or every slide (named `1.png`, -/// `2.png`... like the native export) when `all`. `transparent` leaves the slide -/// background out (PNG only, #784). -pub(crate) fn web_image_files( - doc: &deckcraft_model::Presentation, - current: usize, - all: bool, - name: &str, - ext: &str, - transparent: bool, -) -> Vec<(String, Vec)> { - let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; - let single = slides.len() == 1; - let scale = 2.0; - let dpi = deckcraft_render::dpi_for_scale(scale); - slides - .into_iter() - .enumerate() - .map(|(k, i)| { - let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; - let img = deckcraft_render::render_slide(doc, i, &opts); - let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; - let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; - (file, bytes) - }) - .collect() } diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs new file mode 100644 index 000000000..10778afce --- /dev/null +++ b/crates/ui-egui/src/app/clipboard.rs @@ -0,0 +1,32 @@ +use super::*; + +/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) +/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the +/// shortcut never reached the session and only the context menu could paste objects (#182). An +/// invisible word joiner, so a paste into another app adds nothing visible. +pub(crate) const OBJECTS_MARKER: &str = "\u{2060}"; + +/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. +pub(crate) fn system_clipboard_text(t: &str) -> String { + if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } +} + +impl SlideApp { + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). + pub(super) fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { + return; + } + ctx.copy_text(system_clipboard_text(&text)); + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs new file mode 100644 index 000000000..8f0e462f7 --- /dev/null +++ b/crates/ui-egui/src/app/commands.rs @@ -0,0 +1,276 @@ +use super::*; + +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + +impl SlideApp { + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the + /// status bar and are returned. + pub fn run(&mut self, id: &str, params: Value) -> Result { + let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { + self.run_ui(id, ¶ms) + } else { + self.session.execute(id, ¶ms).map_err(|e| e.to_string()) + }; + match &r { + // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel + // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). + Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { + if let Some(t) = v.get("text").and_then(Value::as_str) { + self.pending_copy = Some(t.to_string()); + } + } + Ok(_) => {} + Err(e) => self.set_status(e.clone()), + } + self.drain_requests(); + r + } + + fn flag(&mut self, params: &Value, cur: bool) -> bool { + params.get("on").and_then(Value::as_bool).unwrap_or(!cur) + } + + fn run_ui(&mut self, id: &str, p: &Value) -> Result { + match id { + "view.normal" => self.ui.view = ViewMode::Normal, + "view.outline" => self.ui.view = ViewMode::Outline, + "view.sorter" => self.ui.view = ViewMode::Sorter, + "view.notesPage" => self.ui.view = ViewMode::NotesPage, + "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), + "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), + "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), + "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } + "view.zoomIn" | "view.zoomOut" => { + let cur = self.ui.zoom.unwrap_or(self.canvas_scale); + let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + let next = if id == "view.zoomIn" { + steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) + } else { + steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) + }; + self.ui.zoom = Some(next); + } + "view.zoom" => { + let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; + self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); + } + "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), + "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } + "view.pane" => { + let pane = p.get("pane").and_then(Value::as_str).map(String::from); + self.ui.pane = + if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; + if let Some(tab) = p.get("tab").and_then(Value::as_str) { + self.ui.format_tab = tab.to_string(); + } + } + "view.tab" => { + let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; + self.ui.tab = tab.to_string(); + self.ui.ribbon_collapsed = false; + } + "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, + "view.dark" => { + let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); + self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; + self.restyle = true; + } + "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), + "view.newWindow" => return doc_window::command(self, p), + "show.start" => { + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + self.start_custom_show(name, false); + } else { + let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); + self.start_show(from, false); + } + } + "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), + "show.end" => { + self.show = None; + self.media.stop_owned(media::Owner::Show); + } + "animation.preview" => { + if p.get("on").and_then(Value::as_bool) == Some(false) { + self.anim_preview = None; + return Ok(json!({"playing": false})); + } + let from = match p.get("from") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), + }; + return self.start_anim_preview(from); + } + "app.home" => { + let on = self.flag(p, self.backstage_open()); + if on { + self.open_backstage(backstage::Page::Home); + } else { + self.close_backstage(); + } + } + "app.backstage" => { + if !p.get("on").and_then(Value::as_bool).unwrap_or(true) { + self.close_backstage(); + return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})); + } + let page = match p.get("page").and_then(Value::as_str) { + Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?, + None => backstage::Page::Home, + }; + if page.needs_doc() && self.session.active().is_none() { + return Err(format!("File › {} needs an open presentation", page.label())); + } + self.open_backstage(page); + return Ok(json!({"page": page.id()})); + } + "app.closePresentation" => self.close_presentation(), + "app.print" => { + if self.session.active().is_none() { + return Err("nothing to print: no presentation is open".into()); + } + return Ok(json!({"printed": self.print_pdf(p.clone())})); + } + "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), + "app.checkUpdates" => { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::request_check(self); + #[cfg(target_arch = "wasm32")] + return Err("updates are handled by the browser: reload the page".into()); + } + "app.palette" => self.palette = Some((String::new(), 0)), + "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), + "app.openDialog" => self.pick_and_open(), + "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.smartArtPictureDialog" => { + if self.services.pick_open.is_none() { + return Err("picking a SmartArt picture needs the desktop app".into()); + } + self.pick_and_run("picture", "smartArt.picture", p); + } + "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), + "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), + "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), + "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), + "app.saveAsDialog" => self.save_as_dialog("deckcraft"), + "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), + "app.browseThemeDialog" => { + if self.session.active().is_none() { + return Err("open a presentation first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("theme", "design.browseTheme"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Browse for Themes needs the desktop app for now".to_string()); + } + } + "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } + "app.dialog" => { + let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; + self.dialog = Some(dialogs::Dialog::new(d)); + } + "app.links" => { + use deckcraft_engine::links::*; + return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); + } + _ => return Err(format!("unknown UI command `{id}`")), + } + Ok(Value::Null) + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs new file mode 100644 index 000000000..f6d73e23f --- /dev/null +++ b/crates/ui-egui/src/app/control.rs @@ -0,0 +1,45 @@ +use super::*; +use crate::control; + +impl SlideApp { + /// Drain pending control requests, executing ordinary requests and admitting screenshots for + /// later saving and reply. + pub(super) fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + // A request whose sender gave up waiting (reply timeout) must not run late (#719). + if !req.take() { + continue; + } + let reply = req.reply.clone(); + let revision = self.session.active().map(|d| d.revision); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + let doc = self.session.active().map(|d| d.uid); + if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { + let _ = reply.send(json!({"ok": false, "error": e})); + } + } + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + self.control_rx = Some(rx); + } + + /// Save completed screenshots on the app thread and send exactly one reply each. + pub(super) fn drain_screenshots(&mut self) { + let doc = self.session.active().map(|d| d.uid); + let completed = self.capture.drain(web_time::Instant::now(), doc); + for c in completed { + let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); + // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. + self.capture.release(c.reserved); + let _ = c.reply.send(v); + } + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs new file mode 100644 index 000000000..c035fcbf1 --- /dev/null +++ b/crates/ui-egui/src/app/files.rs @@ -0,0 +1,664 @@ +use super::*; + +fn purpose_of(purpose: &str) -> FilePurpose { + match purpose { + "open" => FilePurpose::Open, + "picture" => FilePurpose::Picture, + "audio" => FilePurpose::Audio, + "video" => FilePurpose::Video, + _ => FilePurpose::Infer, + } +} + +fn is_presentation_name(lower: &str) -> bool { + lower.ends_with(".deckcraft") + || lower.ends_with(".slidecraft") + || lower.ends_with(".pptx") + || lower.ends_with(".potx") + || lower.ends_with(".ppsx") +} + +/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. +fn open_command(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + Some("file.openBytes") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { + Some("slide.fromOutline") + } else { + None + } +} + +/// A drop has no requested operation, so the name and bytes decide: a presentation opens, +/// then a recognised picture, sound or video is inserted, then an outline becomes slides. +fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + return Some("file.openBytes"); + } + let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); + if ct.starts_with("image/") { + Some("insert.picture") + } else if ct.starts_with("audio/") { + Some("insert.audio") + } else if ct.starts_with("video/") { + Some("insert.video") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { + Some("slide.fromOutline") + } else { + None + } +} + +fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { + let cmd = match purpose { + FilePurpose::Picture => Some("insert.picture"), + FilePurpose::Audio => Some("insert.audio"), + FilePurpose::Video => Some("insert.video"), + FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), + FilePurpose::Open => open_command(name), + FilePurpose::Infer => infer_command(name, bytes), + }; + cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) +} + +impl SlideApp { + /// Does this recent path exist on disk? Probed once per path per session and only on + /// desktop (the web build has no file system to ask): a stat on a dead network share can + /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — + /// opening still tries and reports, and a successful open re-probes. + pub fn recent_exists(&mut self, path: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = path; + true + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ok) = self.recent_checked.get(path) { + return *ok; + } + // A stat on a dead network location (UNC share, URL) can block for the OS timeout, + // so remote-looking paths are never probed: they stay unmarked and are judged when + // opened. A disconnected mapped drive would still stall once — accepted. + if path.starts_with("\\\\") || path.contains("://") { + return true; + } + let ok = std::path::Path::new(path).is_file(); + if self.recent_checked.len() > 64 { + let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); + self.recent_checked.retain(|p, _| live.contains(p)); + } + self.recent_checked.insert(path.to_string(), ok); + ok + } + } + + /// Drop a path from the Recent list (its file is gone or the user asked for it). + pub fn forget_recent(&mut self, path: &str) { + self.ui.recent.retain(|p| p != path); + self.recent_checked.remove(path); + } + + /// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved + /// changes (Save, Don't Save, Cancel), as PowerPoint does. + pub fn close_presentation(&mut self) { + match self.session.active() { + None => {} + Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")), + Some(_) => { + let _ = self.run("file.close", json!({})); + } + } + } + + /// The file name a save or export suggests: the presentation's title with `ext`. + fn suggested_name(&self, ext: &str) -> String { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + format!("{name}.{ext}") + } + + /// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`, + /// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download + /// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The + /// presentation keeps its own file. Returns whether a file was written or handed over. + pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool { + let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false }; + let (ext, format, all) = match fmt { + "pdf" => ("pdf", "pdf", false), + "png" => ("png", "png", false), + "pngAll" => ("png", "png", true), + "jpeg" | "jpg" => ("jpg", "jpeg", false), + "pptx" => ("pptx", "pptx", false), + "outline" | "txt" => ("txt", "outline", false), + "rtf" => ("rtf", "rtf", false), + "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), + other => { + self.set_status(format!("DeckCraft can't export “{other}”")); + return false; + } + }; + let suggested = self.suggested_name(ext); + if let Some(pick) = self.services.pick_save.as_mut() { + let Some(path) = pick(&suggested) else { return false }; + let mut params = json!({"path": path, "format": format, "all": all}); + if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) { + o.extend(x.clone()); + } + return match self.run("file.export", params) { + Ok(_) => { + self.set_status(format!("Exported {path}")); + true + } + Err(_) => false, + }; + } + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; + let bytes = match format { + "png" | "jpeg" => { + // Every slide for "all slides", one download each, named like the desktop export (#64). + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let mut any = false; + let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { + any = self.download(&file, Ok(bytes)) || any; + } + return any; + } + "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), + other => deckcraft_engine::cmd::file::save_bytes(&doc, other), + }; + self.download(&suggested, bytes) + } + + /// Hands `bytes` to the browser as a download (web); false (with the reason in the status + /// bar) when there are no bytes or no way to download. + fn download(&mut self, name: &str, bytes: deckcraft_engine::Result>) -> bool { + match (bytes, self.services.download.as_mut()) { + (Ok(b), Some(dl)) => { + dl(name, &b); + true + } + (Err(e), _) => { + self.set_status(e.to_string()); + false + } + (Ok(_), None) => { + self.set_status("Saving a copy needs a file picker, which this window doesn't have."); + false + } + } + } + + /// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's + /// layout, slides and options) is written to the temporary folder and opened in the system's + /// PDF viewer, which has the printers; on the web it downloads. + pub fn print_pdf(&mut self, params: Value) -> bool { + let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false }; + let mut params = if params.is_object() { params } else { json!({}) }; + if self.services.pick_save.is_none() { + // Web: the browser hands the PDF to the user. + let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); + return self.download(&format!("{title}.pdf"), bytes); + } + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(path)); + o.insert("format".into(), json!("pdf")); + } + match self.run("file.export", params) { + Ok(_) => { + self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); + self.set_status("Opened the presentation as a PDF to print"); + true + } + Err(_) => false, + } + } + + pub(crate) fn pick_and_open(&mut self) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick("open") { + self.open_path_in_background(&path); + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file("open"); + if let Some(f) = self.services.open_async.as_mut() { + f("open", id); + } + } + } + + pub fn open_path(&mut self, path: &str) -> Result<(), String> { + self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.note_recent(path); + Ok(()) + } + + /// Files the system hands over (the command line, a double-click through the file + /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide + /// show starts at once from where Play from Start begins, as PowerPoint does; anything else + /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that + /// couldn't be opened, with the reason. + pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { + let mut failed = Vec::new(); + let mut last = None; + for p in paths { + match self.open_path(p) { + Ok(()) => last = Some(p), + Err(e) => failed.push((p.clone(), e)), + } + } + if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { + self.start_show(self.show_start_index(), false); + } + failed + } + + /// A presentation was opened from `path`: first in the recent list, shown in Normal view. + pub(crate) fn note_recent(&mut self, path: &str) { + self.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(RECENT_MAX); + self.ui.view = ViewMode::Normal; + } + + /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. + pub(crate) fn pick_transition_sound(&mut self) { + if self.services.pick_open.is_some() { + self.pick_and_insert("audio", "transition.sound"); + } + } + + /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the + /// current one (desktop only: the browser picker opens what it picks as a document). + pub(super) fn pick_and_reuse(&mut self, keep: bool) { + let Some(pick) = self.services.pick_open.as_mut() else { + self.set_status("Reuse Slides needs the desktop app".to_string()); + return; + }; + let Some(path) = pick("open") else { return }; + // Read the file once: the list shown and the slides inserted come from the same bytes, even + // if the file changes while the dialog is open. Without a reader the path is used as is. + let mut source = json!({}); + match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + source["name"] = json!(name); + source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + } + Some(Err(e)) => { + self.set_status(e.to_string()); + return; + } + None => source["path"] = json!(path), + } + // Offer the file's slides to choose from; all of them are ticked to begin with. + match self.session.execute("slide.reuseList", &source) { + Ok(list) => { + let mut d = dialogs::Dialog::new("reuseSlides"); + d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]}); + self.reuse_target = Some(self.capture_file_target()); + self.dialog = Some(d); + } + Err(e) => self.set_status(e.to_string()), + } + } + + /// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that + /// `slide.reuseList` listed) after the slide that was current when the picker opened, in that + /// presentation, even if another one is active now; if it is closed or the slide is gone, + /// nothing is inserted. Returns whether they were inserted. + pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool { + let mut args = if source.is_object() { source.clone() } else { json!({}) }; + args["keepFormatting"] = json!(keep); + args["slides"] = json!(picked); + let result = match self.reuse_target { + Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args), + _ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()), + }; + match result { + Ok(()) => { + self.reuse_target = None; + self.set_status(format!("Inserted {} slides", picked.len())); + true + } + Err(e) => { + self.set_status(e); + false + } + } + } + + pub(crate) fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + self.pick_and_run(purpose, cmd, &json!({})); + } + + /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. + pub(super) fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick(purpose) { + let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; + let r = match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + args["name"] = json!(name); + args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + self.session.execute(cmd, &args) + } + _ => { + args["path"] = json!(path); + self.session.execute(cmd, &args) + } + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file(purpose); + // Change Picture: the picture that arrives replaces the selected one (#115). + if cmd == "picture.change" + && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) + { + r.purpose = FilePurpose::ChangePicture; + } + if let Some(f) = self.services.open_async.as_mut() { + f(purpose, id); + } + } + } + + pub fn save_as_dialog(&mut self, format: &str) { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let ext = if format == "pptx" { "pptx" } else { "deckcraft" }; + let suggested = format!("{name}.{ext}"); + if let Some(pick) = self.services.pick_save.as_mut() { + if let Some(path) = pick(&suggested) { + match self.session.execute("file.saveAs", &json!({"path": path})) { + Ok(_) => { + self.set_status(format!("Saved {path}")); + self.ui.recent.retain(|p| *p != path); + self.ui.recent.insert(0, path); + } + Err(e) => self.set_status(e.to_string()), + } + } + } else if let Some(dl) = self.services.download.as_mut() + && let Some(d) = self.session.active() + { + match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) { + Ok(bytes) => dl(&suggested, &bytes), + Err(e) => self.set_status(e.to_string()), + } + } + } + + /// Save every presentation with unsaved changes (asking for a location where needed). + /// Returns false if one wasn't saved (cancelled or failed). + pub fn save_all(&mut self) -> bool { + let dirty: Vec = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect(); + for i in dirty { + self.session.set_active(i); + self.save(); + if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) { + return false; + } + } + true + } + + pub fn save(&mut self) { + if self.session.active().and_then(|d| d.path.clone()).is_some() { + match self.session.execute("file.save", &json!({})) { + Ok(_) => self.set_status("Saved"), + Err(e) => self.set_status(e.to_string()), + } + } else { + self.save_as_dialog("deckcraft"); + } + } + + /// Act on requests commands left for the UI. + pub(super) fn drain_requests(&mut self) { + for r in std::mem::take(&mut self.session.ui_requests) { + match r { + UiRequest::Dialog { id, params } => { + let mut d = dialogs::Dialog::new(&id); + d.params = params; + self.dialog = Some(d); + } + UiRequest::Pane { id } => self.ui.pane = Some(id), + UiRequest::StartShow { from } => self.start_show(from, false), + UiRequest::Message { text } => self.set_status(text), + UiRequest::PickFile { purpose } => { + if purpose == "saveAs" { + self.save_as_dialog("deckcraft"); + } + } + UiRequest::Media { action, shape, ms } => { + let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; + // The command resolved `shape` on the editor's slide, also while a show runs. + if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { + self.media.request(uid, &doc, slide, &action, shape, ms, owner); + } + } + } + } + } + + pub(crate) fn drain_inbox(&mut self) { + let Some(inbox) = self.services.inbox.clone() else { return }; + let events = match inbox.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), + }; + for event in events { + match event { + InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), + InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), + } + } + } + + /// Remember the open presentation, the slide (or master/layout) and the operation for a + /// file the host will read asynchronously. Returns the cancellation identity the host must + /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the + /// bytes arrive. + pub fn begin_external_file(&mut self, purpose: &str) -> u64 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); + id + } + + /// A dropped or picked file read synchronously: presentations open, media is inserted on + /// the slide that is current now. + pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { + let id = self.begin_external_file("drop"); + self.apply_external_file(id, name, bytes); + } + + fn capture_file_target(&self) -> FileTarget { + let Some(d) = self.session.active() else { return FileTarget::Absent }; + let place = match d.selection.target { + Target::Slides => match d.current_slide() { + Some(s) => FilePlace::Slide(s.id), + None => FilePlace::Unspecified, + }, + Target::Master { master } => match d.doc.masters.get(master) { + Some(m) => FilePlace::Master(m.id), + None => FilePlace::Unspecified, + }, + Target::Layout { master, layout } => match d.doc.masters.get(master) { + Some(m) => match m.layouts.get(layout) { + Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, + None => FilePlace::Unspecified, + }, + None => FilePlace::Unspecified, + }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), + }; + FileTarget::Document { doc: d.uid, place } + } + + /// True when the user is still looking at the place this request captured. + fn viewing_target(&self, target: &FileTarget) -> bool { + let FileTarget::Document { doc, place } = target else { return false }; + let Some(d) = self.session.active() else { return false }; + if d.uid != *doc { + return false; + } + match place { + FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), + FilePlace::Master(id) => { + matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) + } + FilePlace::Layout { master, layout } => { + matches!(d.selection.target, Target::Layout { master: mi, layout: li } + if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) + } + FilePlace::NotesPage(t) => d.selection.target == *t, + FilePlace::Unspecified => false, + } + } + + fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { + let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; + let req = self.pending_files.remove(pos); + let cmd = match command_for(req.purpose, name, bytes) { + Ok(cmd) => cmd, + Err(e) => { + self.set_status(e); + return; + } + }; + let params = if cmd == "slide.fromOutline" { + json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) + } else { + json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) + }; + let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { + self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) + } else { + self.execute_on_target(&req.target, name, cmd, ¶ms) + }; + match result { + Err(e) if cmd == "file.openBytes" => self.notify(e), + Err(e) => self.set_status(e), + Ok(()) => {} + } + self.drain_requests(); + } + + /// Run `cmd` on the document and slide captured for the request, then put the view back + /// if the user has since moved. A missing document or slide cancels the file. + fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { + let FileTarget::Document { doc, place } = target else { + self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; + return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + }; + let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { + return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); + }; + let view = self.session.active_index(); + let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); + self.session.set_active(idx); + let located = match self.locate_place(place) { + Ok(located) => located, + Err(e) => { + self.restore_file_view(view); + return Err(format!("{name} was not applied: {e}")); + } + }; + if let Some(st) = self.session.active_mut() { + st.selection.target = located.target; + if let Some(slide) = located.slide { + st.selection.slide = slide; + st.selection.slides.clear(); + } + st.selection.shapes.clear(); + st.selection.text = None; + st.selection.cells = None; + } + let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + if view == Some(idx) { + if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { + st.selection = sel; + } + } else { + self.restore_file_view(view); + } + result + } + + fn restore_file_view(&mut self, view: Option) { + if let Some(i) = view + && i < self.session.documents().len() + { + self.session.set_active(i); + } + } + + fn locate_place(&self, place: &FilePlace) -> Result { + let d = self.session.doc().map_err(|e| e.to_string())?; + match place { + FilePlace::Slide(id) => { + let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) + } + FilePlace::Master(id) => { + let master = + d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Master { master }, slide: None }) + } + FilePlace::Layout { master, layout } => { + let mi = + d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + let li = d + .doc + .masters + .get(mi) + .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) + .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) + } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), + FilePlace::Unspecified => Err("there was no slide to put it on".into()), + } + } +} + +/// Images for the browser download: the current slide, or every slide (named `1.png`, +/// `2.png`... like the native export) when `all`. `transparent` leaves the slide +/// background out (PNG only, #784). +pub(crate) fn web_image_files( + doc: &deckcraft_model::Presentation, + current: usize, + all: bool, + name: &str, + ext: &str, + transparent: bool, +) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + let scale = 2.0; + let dpi = deckcraft_render::dpi_for_scale(scale); + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; + let img = deckcraft_render::render_slide(doc, i, &opts); + let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs new file mode 100644 index 000000000..7a4588105 --- /dev/null +++ b/crates/ui-egui/src/app/frame.rs @@ -0,0 +1,108 @@ +use super::*; + +impl SlideApp { + /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because + /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. + pub(super) fn ui_setup(&mut self, ctx: &egui::Context) -> bool { + let scale = self.ui.ui_scale.clamp(0.5, 3.0); + if (ctx.zoom_factor() - scale).abs() > 1e-3 { + ctx.set_zoom_factor(scale); + } + if !self.styled { + theme::install_fonts_with(ctx, &self.ui_fonts.faces); + self.styled = true; + self.restyle = true; + // Font definitions take effect at the next pass boundary. Do not paint a language + // or symbol with the old font set while the new set is still pending. + ctx.request_discard("install interface fonts before layout"); + return false; + } + if self.restyle { + theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); + self.restyle = false; + } + self.fonts_ready = true; + true + } + + /// AutoRecover on a monotonic deadline, even when nothing is painted. + pub(super) fn autosave_if_due(&mut self, ctx: &egui::Context) { + if self.session.recovery_dir.is_none() { + return; + } + let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); + let now = monotonic_secs(); + if now - self.last_recovery > every { + self.last_recovery = now; + if self.session.documents().iter().any(|d| d.is_dirty()) + && let Err(e) = self.session.execute("file.recovery.save", &json!({})) + { + self.set_status(format!("Couldn't save AutoRecover information: {e}")); + } + } + if self.session.documents().iter().any(|d| d.is_dirty()) { + let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } + + /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real + /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. + pub(super) fn ui_end(&mut self, ctx: &egui::Context) { + self.ime_frame_viewport = None; + self.capture.observe_ui(ctx); + if !ctx.will_discard() { + let doc = self.session.active().map(|d| d.uid); + self.capture.issue(ctx, doc, web_time::Instant::now()); + } + if self.fonts_ready && !ctx.will_discard() { + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + } + self.promote_next_synthetic(); + } + if self.fonts_ready && !ctx.will_discard() { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + } + } + + /// Media clocks. Voices belong to the open presentation: closing the last one, or switching + /// decks, stops them. The editor's media also stops when its slide is left. This runs from + /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. + pub(super) fn tick_media_clock(&mut self, ctx: &egui::Context) { + let uid = self.session.active().map(|d| d.uid); + self.media.set_document(uid); + if !self.media.any_active() { + self.session.media_status.clear(); + return; + } + if self.show.is_none() + && let Some(d) = self.session.active() + { + let here: Vec = if d.selection.target == deckcraft_engine::Target::Slides { + media::slide_media(&d.doc, d.selection.slide).into_iter().map(|m| m.0).collect() + } else { + vec![] + }; + let gone: Vec<_> = + self.media.ids().into_iter().filter(|id| !here.contains(id) && self.media.owner(*id) == Some(media::Owner::Editor)).collect(); + for id in gone { + self.media.stop(id); + } + } + let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); + if playing { + ctx.request_repaint_after(std::time::Duration::from_millis(15)); + } + } +} diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs new file mode 100644 index 000000000..a4bbfee02 --- /dev/null +++ b/crates/ui-egui/src/app/ime.rs @@ -0,0 +1,703 @@ +use super::*; + +impl SlideApp { + /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open + /// presentations' names and text, recent files, and what is typed or composed this frame. + pub(super) fn note_ui_text(&mut self, ctx: &egui::Context) { + let typed: String = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), + _ => None, + }) + .collect() + }); + self.ui_fonts.note(ctx, &typed); + for d in self.session.documents() { + self.ui_fonts.note(ctx, &d.title()); + } + for r in &self.ui.recent { + self.ui_fonts.note(ctx, r); + } + if let Some(st) = self.session.active() { + self.ui_fonts.note_doc(ctx, st); + } + self.ui_fonts.apply(ctx); + } + + /// Stable IME owner for the canvas text target: document, view, shape and table cell. + /// + /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary + /// edits and caret movement keep the same owner. A new document, shape or cell is a new + /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key + /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is + /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. + pub(crate) fn ime_target(&self) -> Option { + let st = self.session.active()?; + let (shape, cell, notes) = match &st.selection.text { + Some(t) => (t.shape, t.cell, t.notes), + None => (*st.selection.shapes.first()?, None, false), + }; + if st.selection.text.is_none() && st.selection.shapes.len() != 1 { + return None; + } + let target = match st.selection.target { + Target::Slides => (0u8, 0usize, 0usize), + Target::Master { master } => (1, master, 0), + Target::Layout { master, layout } => (2, master, layout), + Target::Handout => (3, 0, 0), + Target::NotesMaster => (4, 0, 0), + }; + let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); + Some(egui::ime_context::ImeTarget::new(owner, 0)) + } + + /// The committed surrounding snapshot for the current edit target, or `Unavailable` when + /// there is an owner but no committed text to describe. + /// + /// An unavailable stamp carries the document revision. The tracker requires a strictly + /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a + /// wide selection or a commit that starts from a selected shape would poison the whole frame. + fn ime_receipt_stamp(&self) -> Option { + let st = self.session.active()?; + self.ime_target()?; + Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( + || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), + |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), + )) + } + + /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any + /// earlier or later IME receipt in the same frame. + pub(super) fn note_application_edit(&mut self, ctx: &egui::Context) { + let Some(viewport) = self.ime_frame_viewport else { return }; + if let Some(target) = self.ime_target() { + let frame = ctx.cumulative_frame_nr_for(viewport); + self.ime_tracker.note_application_change(viewport, frame, target); + } + } + + /// Record the expected selection after an accepted IME event. Its own deletion may move + /// the caret/revision; that is not an application interruption of the following commit. + fn refresh_ime_terminal_selection(&mut self) { + let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); + if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { + pending.revision = revision; + pending.anchor = anchor; + pending.caret = caret; + } + } + + /// The current live destination for an IME reset fence. + fn ime_destination(&self) -> Option { + let st = self.session.active()?; + let target = self.ime_target()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) + } + + /// Use the recorded IME viewport only while it matches the current context viewport. + pub(super) fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { + self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) + } + + /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to + /// another viewport's phase. + pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { + self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) + } + + /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. + pub(crate) fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { + if matches!(phase, ImeResetPhase::Clear) { + let _ = self.ime_reset.remove(&viewport); + } else { + self.ime_reset.insert(viewport, phase); + } + } + + /// The live stamp used to compare against the origin/interrupted terminal destination. + fn ime_terminal_stamp(&self) -> Option { + let st = self.session.active()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) + } + + /// Positive route predicate for the canvas IME producer: this pass must be one that can + /// actually paint `canvas::show` for the active document, with no modal, stock field or + /// overlay owning input. + /// + /// Sorter and Notes Page are editor views without a canvas producer, so they are named here + /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or + /// closing window, a missing document/target, a focused stock field, modal dialog, palette, + /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the + /// authority for this pass's actual descriptor; keeping this predicate positive means a view + /// added later is not admitted by omission simply because it does not call `canvas::show`. + pub(crate) fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { + matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) + && self.session.active().is_some() + && self.ime_target().is_some() + && ctx.input(|i| i.viewport().focused.unwrap_or(true)) + && ctx.input(|i| i.viewport().visible() != Some(false)) + && !ctx.input(|i| i.viewport().close_requested()) + && ctx.memory(|m| m.focused().is_none()) + && self.dialog.as_ref().is_none_or(|d| !d.modal()) + && self.palette.is_none() + && self.backstage.is_none() + && self.show.is_none() + && self.opening.is_none() + } + + /// The singleton app's session, composition tracker and terminal guard belong to its owning + /// viewport. View › New Window and Presenter View render through their own immediate + /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only + /// viewport mutate root composition state while only the reset map is per-viewport. Reject it + /// before any state changes instead of pretending to support multi-view editing. + pub(super) fn root_viewport_admitted(ctx: &egui::Context) -> bool { + ctx.viewport_id() == egui::ViewportId::ROOT + } + + /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than + /// standalone logic. + pub(super) fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { + let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); + ctx.data(|d| d.get_temp::(key).unwrap_or(false)) + } + + /// A new application-origin interruption supersedes any previous pending fence. + /// + /// This runs from the logic half of a full pass and from raw input handling, before the + /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: + /// putting a token into egui `Memory` now would let egui attach it to a later stock + /// descriptor, or to a canvas this route does not paint. Retire the superseded id + /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may + /// allocate its one replacement after confirming an eligible custom output. + pub(crate) fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { + let viewport = self.ime_viewport_or(ctx); + if !fresh + && matches!( + self.ime_reset_for(viewport), + ImeResetPhase::Awaiting { viewport: guard_viewport, .. } + | ImeResetPhase::Suspended { viewport: guard_viewport, .. } + if guard_viewport == viewport + ) + { + // This viewport already has a live or suspended guard. The finalizer replaces it + // once if the actual destination changed; do not churn a request per event. + return; + } + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + + /// Custom admission was lost on this route. Retire the old id without inventing an + /// acknowledgement and block until a later eligible pass can request a replacement. + pub(super) fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { + if self.ime_reset_for(viewport).blocks_custom_input() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are + /// inert; this never mutates text, caret or undo history. + pub(super) fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if request_id.viewport_id() != viewport { + return; + } + if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) + && id == request_id + && waiting_viewport == viewport + { + self.set_ime_reset(viewport, ImeResetPhase::Clear); + // The exact ordered completion is the platform-reset boundary. It also releases a + // cleared-preedit terminal guard: the app no longer has to wait for the old + // Commit/Enabled/Disabled before a fresh composition may start. Events ordered + // before this marker remain rejected; a late old terminal after the reset has the + // same documented residual as any other event crossing a platform reset. + if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { + self.ime_terminal_pending = None; + } + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + } + + /// Controls-only routing for a route where the custom editor cannot mutate. Markers are + /// visited in raw order; stock text/key events stay untouched for the stock widgets. + pub(super) fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { + for e in events { + if let egui::Event::ImeResetCompleted { request_id } = e { + self.ime_reset_completed(*request_id, ctx); + } + } + } + + /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. + pub(crate) fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { + let viewport = self.ime_viewport_or(ctx); + let phase = self.ime_reset_for(viewport); + let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); + let destination = self.ime_destination(); + // The finalizer is the allocation authority, so it rechecks the positive canvas route + // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, + // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout + // discard or a missing target must suspend without putting a token into egui Memory. + let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); + let custom_output = match (eligible, custom, actual, destination) { + (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), + _ => None, + }; + let Some((output_context, destination)) = custom_output else { + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { + // A request that was not bound to the actual custom output in this pass is + // retired locally; a later eligible activation obtains a fresh one. A guard + // stamped for another viewport (or a request issued for one) is never reused. + if guard_viewport != viewport || request.is_some() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + ImeResetPhase::AllocationFailed { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + return; + }; + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { + let request = if needs_rearm && guard_viewport == viewport { + // Only a request issued for this exact viewport may be bound here. + request.filter(|id| id.viewport_id() == viewport) + } else { + None + }; + match request.or_else(|| ctx.request_ime_reset(viewport)) { + Some(id) => { + self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); + } + None => { + self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); + } + } + } + ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { + // Repeated idle output for the same destination does not retry allocation. + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + } + } + + /// Request one replacement IME reset token and record `Awaiting` on success or + /// `AllocationFailed` when no token can be allocated. + fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { + match ctx.request_ime_reset(viewport) { + Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), + None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), + } + } + + /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying + /// stale input; the next full UI pass can rearm from its actual descriptor. + pub(super) fn finalize_ime_logic(&mut self, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if self.ime_reset_for(viewport).blocks_custom_input() { + // Only this viewport's guard is touched. A root/child logic tick must not suspend or + // rebind another viewport's independent reset lifetime. + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// Note compositions the application settled since the last check (a click, a key, another + /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], + /// and a platform event for them is stale from that moment on, even in the same pass. + pub(crate) fn note_settled_composition(&mut self, ctx: &egui::Context) { + let settles = self.session.composition_settles(); + let current_stamp = self.ime_terminal_stamp(); + // `file.close` then `file.new` can replace the active uid before any pass observes the + // empty document. That is not a safe boundary for the old terminal: the platform may + // still deliver its suffix. Keep the guard bound to its retired owner and treat the new + // destination as a fresh interruption; only exact reset completion or an observed + // teardown may clear the terminal metadata. + let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { + let reference = if p.interrupted { + p.interrupted_at + } else { + Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) + }; + reference != current_stamp + }); + if settles == self.ime_settles_seen && !pending_interrupted { + return; + } + let fresh = settles != self.ime_settles_seen; + self.ime_settles_seen = settles; + if let Some(pending) = &mut self.ime_terminal_pending { + pending.cancelled = true; + pending.interrupted = true; + // Compare later passes against the destination where interruption actually happened, + // not forever against the retired composition's origin. The destination may be absent + // (the interruption closed its target); `interrupted` keeps that distinct from "not + // interrupted yet". + pending.interrupted_at = current_stamp; + } + self.ime_interrupt_pending = true; + self.note_ime_interruption(ctx, fresh); + let cancelled = self.ime_composition.as_mut().map(|c| { + c.settled = true; + (c.owner, c.generation) + }); + if let Some((owner, generation)) = cancelled { + // Retain a tombstone beyond the first stale event: clearing the composition must + // not let the rest of this input batch commit into the next target. `observe_ime` + // retires it in the pass that publishes the matching interrupt. + self.ime_cancelled = Some(ImeCancelled { owner, generation }); + } + } + + /// An input method's event for the slide's text (#101): the preedit is shown in the text and + /// replaced on each update; the commit replaces it as one undo step. + /// + /// An event only ever changes the target its composition began in. The application settles a + /// composition itself on a click, a key, a command or a target change, and the platform may + /// still hold it: pre-edits and commits queued for that generation in the settling input batch + /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is + /// selected now. After the interrupt has been published the platform is reset, so the next + /// batch is treated as new input; egui's event payload has no generation, so an OS-queued + /// event that crosses that reset cannot be distinguished from a legitimate one. + /// + /// A receipt is captured immediately before and after a successful commit or + /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn + /// [`egui::output::ImeChangeCause::InputMethod`]. + pub(crate) fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { + self.note_settled_composition(ctx); + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + let frame = ctx.cumulative_frame_nr_for(viewport); + let target = self.ime_target(); + let owner = target.map(|t| t.owner); + let accepted = + matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); + if self.reject_stale_ime_event(ctx, e, owner) { + return; + } + // Until the exact newest reset request completes at its ordered stream position, custom + // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do + // not release this guard; they still settle the old composition below. + #[allow(deprecated)] + if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { + return; + } + let before = if accepted { self.ime_receipt_stamp() } else { None }; + let r = self.apply_ime_event(e); + self.settle_ime_event_state(e, owner, r.is_ok()); + if r.is_ok() && accepted { + self.refresh_ime_terminal_selection(); + } + if let Err(e) = &r { + self.set_status(e.to_string()); + } else if accepted + && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) + && before != after + && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) + { + // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is + // not a mutation and must not poison the frame for a real commit later in the batch. + self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); + } + } + + /// Drop an event that belongs to a composition the application already settled, or to another + /// owner; returns `true` when the event must not be applied. + /// + /// A commit or deletion belongs to the composition that began the pre-edit: another owner + /// (the user clicked elsewhere) or a composition the app already settled makes it stale. + /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has + /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass + /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the + /// backend just the same), so a later commit-only is not lost forever: it is a + /// legitimate direct commit or a new platform composition. egui's events carry no + /// generation, so an event queued by the OS across that reset is indistinguishable from + /// one the user just typed; only events in the same input batch as the settle are rejected + /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner + /// remains guarded across that output boundary until its actual commit/enable/disable + /// arrives. Publishing a reset is not evidence that the separately queued terminal was + /// delivered. With no incoming event epoch, the first ambiguous new composition after an + /// unobserved reset may be rejected; no timer guesses that it became safe. + /// The tombstone is not filtered by the *current* target: a click can change the owner in + /// the settling pass, and every remaining event of that batch still belongs to the + /// pre-reset composition. Filtering would let the event after the first dropped pre-edit + /// start composing in the new target before the platform is reset. + fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { + let cancelled = self.ime_cancelled; + let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); + #[allow(deprecated)] + let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) + && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); + if !stale { + return false; + } + if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { + // The old terminal finally arrived: reject it and release its pending owner now, + // so a new preedit later in this same raw batch can belong to the current target. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + return true; + } + if pending_cancelled { + // note_settled_composition already queued the reset. Do not also latch the + // frame's memory interrupt: a terminal later in this batch can consume it and + // permit a fresh preedit without resetting that new composition at pass end. + self.ime_composition = None; + return true; + } + if let Some(c) = cancelled { + log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); + } + self.ime_composition = None; + // Ask the backend to drop its copy, so this is one stale event and not a stream. + ctx.memory_mut(|m| m.interrupt_ime()); + true + } + + /// Apply the document mutation an IME event asks for, before any receipt or tracker state. + fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { + match e { + egui::ImeEvent::Preedit { text, active_range_chars } => { + self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) + } + // A commit only replaces text that is being edited. It must never start editing a + // merely selected shape: without a tracked pre-edit, a stale platform commit would + // replace that shape's whole text. + egui::ImeEvent::Commit(text) => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.commit_text(text) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_chars, *after_chars, false) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_bytes, *after_bytes, true) + } else { + Ok(Value::Null) + } + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // The platform ended (or is about to restart) its composition itself. + self.session.end_composition(); + Ok(Value::Null) + } + } + } + + /// Update the tracked composition after `e` has been applied; `applied` says whether the + /// document mutation succeeded. + fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { + match e { + // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is + // left to interrupt. A pre-edit keeps (or starts) the tracked composition. + egui::ImeEvent::Commit(_) => { + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // A platform-side disable/enable already reset the composition. Do not carry an + // older app-settled interrupt into the next (possibly new) composition. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + egui::ImeEvent::Preedit { text, .. } => { + match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { + (Some(owner), true, true) => { + // Updating the same composition keeps its generation. After a settle (or + // a first pre-edit) this is a new generation and clears the tombstone. + let generation = match self.ime_composition { + Some(c) if c.owner == owner && !c.settled => c.generation, + _ => { + self.ime_generation = self.ime_generation.wrapping_add(1).max(1); + self.ime_generation + } + }; + self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); + self.ime_terminal_pending = None; + self.ime_cancelled = None; + } + (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { + // The model restored committed text, but no terminal was delivered. Keep + // this owner across passes; a later pointer/key/target change must reject + // its separately queued old delete/commit, even after an outgoing reset. + if let Some(st) = self.session.active() + && let Some(t) = &st.selection.text + { + self.ime_terminal_pending = Some(ImeTerminalPending { + owner, + revision: st.revision, + anchor: t.anchor, + caret: t.caret, + cancelled: false, + interrupted: false, + interrupted_at: None, + }); + } + } + _ => { + self.ime_composition = None; + self.ime_terminal_pending = None; + } + } + } + _ => {} + } + } + + /// Observe the canvas IME owner once per pass and publish its descriptor. + /// + /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, + /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means + /// the custom owner is hidden: the tracker is told so and the next focus starts a new + /// generation. The descriptor is rebuilt from the current committed text every pass. + /// + /// A composition the application settled itself is announced once as + /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That + /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit + /// terminal retains its separate pending owner until the incoming terminal is observed. + pub(crate) fn observe_ime(&mut self, ctx: &egui::Context) -> Option { + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + self.note_settled_composition(ctx); + // The editing window going away really ends the viewport's context; a merely hidden + // owner (modal, palette, unfocused field) stays observed as unfocused instead. + if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { + self.ime_interrupt_pending = false; + if let Some(old) = self.ime_viewport.take() { + self.ime_tracker.end_viewport(old); + // Explicit viewport destruction removes only that viewport's guard. + let _ = self.ime_reset.remove(&old); + } + let _ = self.ime_reset.remove(&viewport); + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_terminal_pending = None; + return None; + } + if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { + // The native window itself lost focus: no custom editor is eligible this pass, even + // if the model still has a text selection and a last-pass geometry rect. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(rect) = self.ime_pending.take() else { + // No canvas editor was painted in this pass. A discarded font/layout pass publishes + // nothing the platform sees, so keep the interrupt and tombstone for a later pass. + // An accepted pass is a backend reset only when it publishes *no* IME descriptor at + // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its + // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate + // retirement on the actual committed output, not on the absence of the canvas rect; + // otherwise the next legitimate commit in the focused field would be rejected. + let no_descriptor = ctx.output(|o| o.ime.is_none()); + if !ctx.will_discard() && no_descriptor { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + self.ime_tracker.observe(viewport, None); + return None; + }; + if !self.ime_custom_canvas_route(ctx) { + // The canvas can still paint behind an opening overlay or a Sorter/Notes Page + // reroute, and a forced hidden paint can run after the window lost focus or + // visibility. The positive route and the UI wrapper both reject those, so a pending + // rect must not republish the custom owner. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(st) = self.session.active() else { + self.ime_interrupt_pending = false; + self.ime_tracker.observe(viewport, None); + return None; + }; + let Some(target) = self.ime_target() else { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + self.ime_tracker.observe(viewport, None); + return None; + }; + if self.ime_viewport != Some(viewport) + && let Some(old) = self.ime_viewport.replace(viewport) + { + self.ime_tracker.end_viewport(old); + let _ = self.ime_reset.remove(&old); + } + let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); + let mut observation = + egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) + .purpose(egui::IMEPurpose::Normal) + .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); + if committed.is_none() { + observation = observation.unavailable_revision(st.revision); + } + let update = self.ime_tracker.observe(viewport, Some(observation))?; + // Clear the interrupt only once it is part of a settled pass's output descriptor. A + // discarded font/layout pass publishes nothing the platform sees, so keep it pending; + // that pass is not the boundary at which stale pre-edits become a new generation. + let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; + if interrupt { + // This pass's output carries the backend reset. The tombstone did its job for the + // events already processed in this batch; from the next batch on every pre-edit or + // commit is a new platform generation. A late event the OS queued across the reset + // has no generation in its payload, so it cannot be rejected without also dropping + // legitimate direct commits (documented residual in `ime_event`). + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect: rect, + should_interrupt_composition: interrupt, + reset_request: None, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + Some(key) + } +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 000000000..463fbb8c4 --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,270 @@ +use super::*; + +impl SlideApp { + /// What a queued synthetic event is meant for. The control protocol hands out window + /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and + /// the next UI pass makes the event stale. + pub(crate) fn input_target(&self) -> InputTarget { + let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; + InputTarget { + documents: self.session.documents().len(), + active: self.session.active().map(|d| d.uid), + slide: self.session.active().map(|d| d.selection.slide), + view: self.ui.view, + zoom: self.ui.zoom.map(bits), + canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), + canvas_scale: bits(self.canvas_scale), + dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), + palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), + backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), + ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), + ribbon_collapsed: self.ui.ribbon_collapsed, + ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), + show: self.show.as_ref().map(show::Show::input_state), + opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), + } + } + + /// Queue one synthetic control-channel event. The target is recorded when the batch starts, + /// before any host command in the same logic pass can change the document. + pub fn queue_input(&mut self, event: egui::Event) { + let target = self.input_target(); + // A batch that was already injected keeps the target it was injected for until its UI + // pass consumes it or retracts it. New events queued meanwhile belong to the current + // target, so they wait in a separate batch instead of joining the old one and being + // cleared with it when that batch turns out to be stale. + if self.synthetic_pending { + if self.synthetic_next_target != Some(target) { + self.synthetic_next.clear(); + self.synthetic_next_target = Some(target); + } + self.synthetic_next.push(event); + return; + } + if self.synthetic.is_empty() { + self.synthetic_target = Some(target); + } else if self.synthetic_target != Some(target) { + // The unsent queue belongs to a target that has already changed; drop it (and any + // older batch still waiting behind it) rather than appending a click intended for the + // new target to a stale batch. + self.synthetic.clear(); + self.synthetic_next.clear(); + self.synthetic_next_target = None; + self.synthetic_target = Some(target); + } + self.synthetic.push(event); + } + + /// Events admitted by the control channel but not yet injected (including a batch waiting + /// behind the one currently in flight). + pub(crate) fn synthetic_queued_len(&self) -> usize { + self.synthetic.len() + self.synthetic_next.len() + } + + /// Forget the injected or queued synthetic batch and everything recorded about it. Events + /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. + fn clear_synthetic_batch(&mut self) { + self.synthetic.clear(); + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_target = None; + self.synthetic_focus = None; + } + + /// Start the batch that was queued behind an older one once the older batch is gone. + pub(super) fn promote_next_synthetic(&mut self) { + if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { + return; + } + self.synthetic = std::mem::take(&mut self.synthetic_next); + self.synthetic_target = self.synthetic_next_target.take(); + self.synthetic_focus = None; + } + + /// A font/layout pass will be discarded: egui already consumed the injected events but the + /// app deliberately lays nothing out, so put them back at the head of the queue. The next + /// frame injects them again after the new fonts are installed; otherwise a control click that + /// happened to arrive on the very first frame would be acknowledged without ever reaching the + /// UI. + pub(super) fn requeue_injected_synthetic(&mut self) { + if !self.synthetic_pending { + return; + } + let mut combined = std::mem::take(&mut self.synthetic_injected); + combined.append(&mut self.synthetic); + self.synthetic = combined; + self.synthetic_pending = false; + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + self.promote_next_synthetic(); + } + } + + /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried + /// first so an identical host event earlier in the batch is never removed; the last exact + /// match is only a fallback for a raw input that was rewritten around the events. + fn drain_synthetic_from(&self, events: &mut Vec) -> bool { + let injected = &self.synthetic_injected; + let n = injected.len(); + if n == 0 { + return false; + } + let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); + let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); + if let Some(at) = at { + events.drain(at..at + n); + true + } else { + false + } + } + + /// Drop an injected batch whose document, canvas, focus or modal owner changed during + /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained + /// there can still retarget the same `RawInput`. + /// + /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run + /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or + /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* + /// pass; remove the batch only when the live input carries exactly what this pass injected + /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending + /// bookkeeping so the next `raw_input_hook` can retract the events from the carried + /// `RawInput` before eframe delivers them in a full pass. + pub(super) fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { + if !self.synthetic_pending { + return; + } + let current = self.input_target(); + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_target == Some(current) && focus == self.synthetic_focus { + return; + } + let injected_time = self.synthetic_injected_time; + let mut removed = false; + ctx.input_mut(|i| { + // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the + // time of the RawInput this batch was injected into. Never drain an older batch that + // happens to look similar. + if i.raw.time != injected_time { + return; + } + let mut events = std::mem::take(&mut i.events); + let ok = self.drain_synthetic_from(&mut events); + i.events = events; + if ok { + // Keep `InputState::raw` consistent with what UI code reads as `events`. + let mut raw_events = std::mem::take(&mut i.raw.events); + let _ = self.drain_synthetic_from(&mut raw_events); + i.raw.events = raw_events; + removed = true; + } + }); + if removed { + self.clear_synthetic_batch(); + self.promote_next_synthetic(); + } else { + // Logic-only (or a pass whose input does not contain the batch): the events are in + // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the + // stale target so the next hook retracts them there. Events queued after the batch + // belong to the same stale target as well. + self.synthetic.clear(); + } + } + + /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is + /// the full pass the batch was admitted for and egui has already derived pointer/key state + /// from it. A logic-only callback leaves the previous pass's `InputState` (with another + /// `raw.time`) and must not be mistaken for the live pass, so control/background work is + /// still drained there and the next `raw_input_hook` retracts a batch that went stale. + pub(super) fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { + if !self.synthetic_pending || self.synthetic_injected.is_empty() { + return false; + } + let Some(injected_time) = self.synthetic_injected_time else { return false }; + let injected = &self.synthetic_injected; + ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) + } + + /// Input that belongs to the pass about to run: native dropped files and one synthetic + /// control-channel batch. The synthetic batch is injected at most once; while it is + /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change + /// drops the whole queued batch rather than typing into the wrong place. + /// + /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will + /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe + /// carries the whole `RawInput` into the next full pass, so an already-injected batch is + /// retracted at the next hook if a control request changed the target, and otherwise is + /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase + /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + #[cfg(not(target_arch = "wasm32"))] + for f in std::mem::take(&mut raw.dropped_files) { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); + match f.bytes() { + Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), + Err(e) => self.set_status(e), + } + } + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_pending { + let current = self.input_target(); + if self.synthetic_target != Some(current) || focus != self.synthetic_focus { + // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` + // and appends the next host input to it. The events are already in `raw.events`; + // clearing only the unsent queue would still deliver the stale click or key into + // the new document on the next full pass. Retract exactly the batch we appended. + let _ = self.drain_synthetic_from(&mut raw.events); + self.clear_synthetic_batch(); + // New control input queued for the current target while the stale batch was in + // flight is a separate batch; keep it instead of clearing it with the old one. + self.promote_next_synthetic(); + } else { + // This RawInput is the carrier for the next pass, and eframe refreshes its time + // on every call. Record the current time so `retract_synthetic_after_logic` can + // tell this full pass's live `InputState` from a logic-only callback's stale one. + self.synthetic_injected_time = raw.time; + } + } + if self.synthetic_pending || self.synthetic.is_empty() { + return; + } + let current = self.input_target(); + if self.synthetic_target != Some(current) { + // The batch was queued for a document/slide/canvas that has since changed. + self.synthetic.clear(); + self.synthetic_target = None; + self.promote_next_synthetic(); + if self.synthetic.is_empty() { + return; + } + if self.synthetic_target != Some(current) { + // The newer batch is stale too: drop it rather than delivering it to this target. + self.synthetic.clear(); + self.synthetic_target = None; + self.synthetic_next.clear(); + self.synthetic_next_target = None; + return; + } + } + self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); + log::debug!("injecting synthetic input batch {}", self.synthetic_generation); + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let n = n.min(self.synthetic.len()); + let batch: Vec = self.synthetic.drain(..n).collect(); + let at = raw.events.len(); + raw.events.extend(batch.iter().cloned()); + self.synthetic_injected = batch; + self.synthetic_injected_at = Some(at); + self.synthetic_injected_time = raw.time; + self.synthetic_pending = true; + self.synthetic_focus = focus; + } +} diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs new file mode 100644 index 000000000..2d8218564 --- /dev/null +++ b/crates/ui-egui/src/app/keys.rs @@ -0,0 +1,251 @@ +use super::clipboard::OBJECTS_MARKER; +use super::*; + +impl SlideApp { + /// The app shortcuts that still make sense on the empty File/start screen: create, open, + /// the command palette, preferences and the File tab itself. Document commands (save, find, + /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. + fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { + use egui::Key::*; + matches!( + (key, m.cmd, m.shift, m.alt), + (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) + ) + } + + /// File-tab navigation and document-free shortcuts, also used on the start screen. + fn route_backstage_keys(&mut self, events: Vec) { + if self.backstage.is_none() { + return; + } + // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app + // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the + // hidden presentation. + let start_screen = self.session.active().is_none(); + for e in events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + let handled = backstage::key(self, key, mods) + // With no presentation the File home only owns the shortcuts that create or + // open one. Borrowing the whole document shortcut table here would also arm + // Save/Save As, hyperlink, find/replace, show and zoom against the empty + // start screen, which is a scope expansion no editing route needs. + || (mods.cmd || mods.alt) + && (!start_screen || Self::start_screen_shortcut(key, mods)) + && menus::ui_shortcut(self, key, mods); + if handled { + continue; + } + // Engine shortcuts that don't touch the hidden slide (Ctrl+N). + if mods.cmd + && matches!(key, egui::Key::N) + && let Err(e) = self.session.key(key_name(key), mods) + { + self.set_status(e.to_string()); + } + } + } + } + + /// Keyboard and clipboard for the slide (when no text field has focus). + pub(super) fn keyboard(&mut self, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the + // key press, so the release of a V press never seen is the paste. Tracked even while a text + // field has focus, so that a V typed there is not taken for one. + let mut image_paste = false; + let mut pasted_image_event = false; + for e in &events { + match e { + egui::Event::PasteImage(_) => { + pasted_image_event = true; + self.v_seen = true; + } + egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, + egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { + // Only a release with Command/Ctrl held is a paste: a bare V release is not. + let seen = std::mem::take(&mut self.v_seen); + image_paste = !seen && (modifiers.command || modifiers.ctrl); + } + _ => {} + } + } + let viewport = self.ime_viewport_or(ctx); + if self.session.active().is_none() { + // No document: keep slide-editing input suspended, while the start screen's + // File-tab navigation and document-free shortcuts remain available. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { + self.route_backstage_keys(events); + } + self.drain_requests(); + return; + } + let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); + // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow + // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon + // buttons), and that stray focus would swallow every later key and end text entry. Let it + // go; a real text field or an open menu keeps its keys. + if !modal + && self.session.active().is_some_and(|d| d.selection.text.is_some()) + && !ctx.text_edit_focused() + && !ctx.any_popup_open() + && let Some(id) = ctx.memory(|m| m.focused()) + { + ctx.memory_mut(|m| m.surrender_focus(id)); + } + if ctx.memory(|m| m.focused().is_some()) || modal { + // A stock field/modal/palette owns input now. Retire the custom fence, then visit + // controls in raw order; stock text/key events are left for the stock widgets. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + return; + } + if self.backstage.is_some() { + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + self.route_backstage_keys(events); + self.drain_requests(); + return; + } + let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + if let Some(png) = img { + let data = deckcraft_engine::cmd::base64_encode(&png); + if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { + self.set_status(e.to_string()); + } + } + for e in events { + // An earlier command in this raw batch may have settled the composition. Request the + // replacement at this ordered boundary, before the current event is considered. + self.note_settled_composition(ctx); + match e { + egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), + egui::Event::PasteImage(image) => match color_image_png(&image) { + Ok(png) => { + let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); + if let Err(e) = self.session.execute("insert.picture", ¶ms) { + self.set_status(e.to_string()); + } + } + Err(e) => self.set_status(e), + }, + egui::Event::Text(t) => { + let revision = self.session.active().map(|d| d.revision); + if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) + && let Err(e) = self.session.type_text(&t) + { + self.set_status(e.to_string()); + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + egui::Event::Ime(ime) => self.ime_event(ctx, &ime), + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), + egui::Event::Paste(text) => { + // Our own marker for objects without text: paste what the session copied. + let text = if text == OBJECTS_MARKER { String::new() } else { text }; + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); + // egui 0.37 sends `PasteImage` directly; the services fallback stays for + // integrations that still send an empty `Paste`. Never insert twice. + let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + let revision = self.session.active().map(|d| d.revision); + let r = match img { + Some(png) => self + .session + .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Tab { + // The slide has the keyboard, so Tab is ours (indent, tab character, next + // shape). Left to egui it would also focus the first ribbon widget, and + // every key after it would go there instead of the slide (#78). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { + self.anim_preview = None; + continue; + } + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { + continue; + } + let name = key_name(key); + let revision = self.session.active().map(|d| d.revision); + match self.session.key(name, mods) { + Ok(_) => {} + Err(e) => self.set_status(e.to_string()), + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + _ => {} + } + } + self.drain_requests(); + } +} + +/// Engine key name for an egui key. +pub fn key_name(key: egui::Key) -> &'static str { + use egui::Key::*; + match key { + Enter => "Enter", + Escape => "Escape", + Backspace => "Backspace", + Delete => "Delete", + Tab => "Tab", + ArrowLeft => "Left", + ArrowRight => "Right", + ArrowUp => "Up", + ArrowDown => "Down", + Home => "Home", + End => "End", + PageUp => "PageUp", + PageDown => "PageDown", + Space => "Space", + F2 => "F2", + F5 => "F5", + F7 => "F7", + other => other.name(), + } +} + +/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as +/// background removal. The image was already allocated by the integration; this keeps the +/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. +const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; + +/// Encode an egui color image as the PNG bytes `insert.picture` stores. +fn color_image_png(image: &egui::ColorImage) -> Result, String> { + let [w, h] = image.size; + let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; + if pixels == 0 { + return Err("the pasted image is empty".into()); + } + if pixels > MAX_PASTE_IMAGE_PIXELS { + return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); + } + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) +} diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs new file mode 100644 index 000000000..33b173d3a --- /dev/null +++ b/crates/ui-egui/src/app/playback.rs @@ -0,0 +1,171 @@ +use super::*; + +impl SlideApp { + /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the + /// thumbnail pane has been clicked. + pub(crate) fn slides_list_focused(&self) -> bool { + self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) + } + + /// First slide Play from Start should open: the custom show or range, skipping hidden slides. + pub fn show_start_index(&self) -> usize { + self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) + } + + /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of + /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or + /// opens on the end screen. Hidden slides are allowed, as in "from current slide". + pub(crate) fn show_from(&self, p: &Value) -> Result, String> { + let from = match p.get("from") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, + }; + let count = self.session.active().map_or(0, |d| d.doc.slides.len()); + if from >= count { + return Err(format!("no slide {from}")); + } + Ok(Some(from)) + } + + /// Leave animation preview, stop editor-owned media, and create a show at the requested slide + /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. + pub fn start_show(&mut self, from: usize, reading: bool) { + self.anim_preview = None; + self.media.stop_owned(media::Owner::Editor); + if let Some(d) = self.session.active() { + self.show_instance = self.show_instance.wrapping_add(1).max(1); + let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); + show.instance = self.show_instance; + self.show = Some(show); + } + } + + /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. + pub fn start_custom_show(&mut self, name: &str, reading: bool) { + let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); + let Some(state) = state else { return }; + let from = state.slide; + self.start_show(from, reading); + if let Some(show) = self.show.as_mut() { + show.state = state; + } + } + + /// The current slide's animations are playing on the editor canvas. + pub fn anim_preview_active(&self) -> bool { + self.anim_preview.is_some() + } + + /// `(slide, seconds played, sequence length)` while a preview is running. + pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { + self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) + } + + pub fn stop_anim_preview(&mut self) { + self.anim_preview = None; + } + + /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. + pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { + self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) + } + + /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence + /// (plus a short hold) has finished. + pub fn tick_anim_preview(&mut self, now: f64) -> bool { + let Some(p) = self.anim_preview.as_ref() else { return false }; + let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); + let alive = self.show.is_none() + && self.session.active().is_some_and(|d| { + d.uid == uid + && d.selection.target == deckcraft_engine::Target::Slides + && d.selection.slide == slide + && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) + }); + if !alive { + self.anim_preview = None; + return false; + } + let Some(p) = self.anim_preview.as_mut() else { return false }; + let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; + let start = *p.started.get_or_insert(now); + let elapsed = p.from + (now - start).max(0.0); + if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { + self.anim_preview = None; + return false; + } + p.elapsed = elapsed; + true + } + + /// Play the current slide's animations on the canvas, from effect `from` (index into the slide's + /// animations) or from the start. + pub(super) fn start_anim_preview(&mut self, from: Option) -> Result { + let (uid, slide, ptr, timeline) = { + let Some(d) = self.session.active() else { + return Err("no presentation is open".into()); + }; + if d.selection.target != deckcraft_engine::Target::Slides { + return Err("animation preview plays the current slide".into()); + } + let slide = d.selection.slide; + let Some(slide_arc) = d.doc.slides.get(slide) else { + return Err("no slide to preview".into()); + }; + let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; + let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); + (d.uid, slide, ptr, timeline) + }; + if timeline.is_empty() { + self.anim_preview = None; + self.set_status("This slide has no animations"); + return Ok(json!({"playing": false})); + } + let from_secs = match from { + Some(i) => timeline.start_of(i).ok_or_else(|| format!("no effect {i} in this slide's click sequence"))?, + None => 0.0, + }; + if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { + self.ui.view = ViewMode::Normal; + } + self.close_backstage(); + let duration = timeline.total_duration(); + self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); + self.set_status("Previewing animations"); + Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) + } + + /// Keep a hidden show playing from logic alone. `run_logic` does not refresh + /// `InputState::time`, so the show anchors its last visible time to this monotonic instant + /// and advances from there. A forced hidden paint draws the current state but does not + /// advance it a second time, and no paused-show product policy is invented. + pub(crate) fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { + let visible = show::viewport_visible(ctx); + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; + let Some(show) = self.show.as_mut() else { return }; + if visible { + // A visible logic-only pass (`run_logic`) carries the last full frame's input time. + // Anchoring that stale time to `now` would move the hidden clock backwards if the + // window hides before the next full pass. `show::ui` already records the anchor on + // every visible full pass, where `InputState::time` is current. + return; + } + if !show.hidden_started() { + return; + } + let input_time = ctx.input(|i| i.time); + let show_time = show.hidden_logic_time(input_time, now); + let keep = show.advance_hidden(&doc, show_time); + let time_driven = show.hidden_time_driven(&doc); + if !keep { + self.show = None; + return; + } + if time_driven { + // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the + // logic clock ticking; with no paint request pending eframe services it as a + // logic-only pass, not a visible frame. + ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); + } + } +} From af9b8c1adbe4f7cd5981fca5300c1752dbb6210c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:36:43 +0000 Subject: [PATCH 23/90] refactor(ui-egui): rename panes to panels --- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/{panes.rs => panels.rs} | 0 2 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{panes.rs => panels.rs} (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2ec615902..bb70bd066 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,7 +21,6 @@ pub mod media; pub mod menus; pub mod notes; pub mod opening; -pub mod panes; mod preview; pub mod qat; pub mod ribbon; @@ -53,6 +52,7 @@ pub use control::{ControlRequest, ControlResponse}; mod app; mod clock; mod ime_hooks; +pub mod panels; mod services; mod state; @@ -61,6 +61,7 @@ pub use app::key_name; use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; +pub use panels as panes; pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp}; pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE}; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panels.rs similarity index 100% rename from crates/ui-egui/src/panes.rs rename to crates/ui-egui/src/panels.rs From 62aa7aa437f230f71e1933f7363e37e836f53f17 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:51:52 +0000 Subject: [PATCH 24/90] refactor(ui-egui): move thumbs to panels/thumbs.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/{ => panels}/thumbs.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => panels}/thumbs.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb70bd066..b7846d185 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,7 +32,6 @@ pub mod squiggles; pub mod status; pub mod textures; pub mod theme; -pub mod thumbs; pub mod toast; pub mod uifonts; #[cfg(not(target_arch = "wasm32"))] @@ -62,6 +61,7 @@ use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; +pub use panels::thumbs; pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp}; pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE}; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 74482f930..315f13104 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1494,6 +1494,7 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec #[cfg(test)] #[path = "smartart_tests.rs"] mod smartart_tests; +pub mod thumbs; #[cfg(test)] #[path = "background_tests.rs"] diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/panels/thumbs.rs similarity index 100% rename from crates/ui-egui/src/thumbs.rs rename to crates/ui-egui/src/panels/thumbs.rs From 59e4b272d86e49edc4d3dd7dc29203cc55bc1fc9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:54:44 +0000 Subject: [PATCH 25/90] refactor(ui-egui): move selpane to panels/selpane.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/{ => panels}/selpane.rs | 1 - .../src/{selpane_tests.rs => panels/selpane/ui_tests.rs} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{ => panels}/selpane.rs (99%) rename crates/ui-egui/src/{selpane_tests.rs => panels/selpane/ui_tests.rs} (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7846d185..ba9c66c43 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,7 +24,6 @@ pub mod opening; mod preview; pub mod qat; pub mod ribbon; -pub mod selpane; pub mod settings; pub mod show; pub mod sorter; @@ -61,6 +60,7 @@ use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; +pub use panels::selpane; pub use panels::thumbs; pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp}; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 315f13104..ee450a505 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1491,6 +1491,7 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec }); } +pub mod selpane; #[cfg(test)] #[path = "smartart_tests.rs"] mod smartart_tests; diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/panels/selpane.rs similarity index 99% rename from crates/ui-egui/src/selpane.rs rename to crates/ui-egui/src/panels/selpane.rs index 1e4c54647..048eff3ff 100644 --- a/crates/ui-egui/src/selpane.rs +++ b/crates/ui-egui/src/panels/selpane.rs @@ -438,5 +438,4 @@ mod tests { } #[cfg(test)] -#[path = "selpane_tests.rs"] mod ui_tests; diff --git a/crates/ui-egui/src/selpane_tests.rs b/crates/ui-egui/src/panels/selpane/ui_tests.rs similarity index 100% rename from crates/ui-egui/src/selpane_tests.rs rename to crates/ui-egui/src/panels/selpane/ui_tests.rs From 82fb8e768073abeca8724633b3b036f844d0fda5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:58:15 +0000 Subject: [PATCH 26/90] refactor(ui-egui): move notes to panels/notes.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/{ => panels}/notes.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => panels}/notes.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ba9c66c43..660a8a531 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,7 +19,6 @@ pub mod fillui; pub mod icons; pub mod media; pub mod menus; -pub mod notes; pub mod opening; mod preview; pub mod qat; @@ -60,6 +59,7 @@ use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; +pub use panels::notes; pub use panels::selpane; pub use panels::thumbs; pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ee450a505..2aa7577b0 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1491,6 +1491,7 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec }); } +pub mod notes; pub mod selpane; #[cfg(test)] #[path = "smartart_tests.rs"] diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/panels/notes.rs similarity index 100% rename from crates/ui-egui/src/notes.rs rename to crates/ui-egui/src/panels/notes.rs From dcd06db2d3d85d5dc66aa4df2b677cb0228b97b6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:59:05 +0000 Subject: [PATCH 27/90] refactor(ui-egui): move fillui to panels/fillui.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/{ => panels}/fillui.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => panels}/fillui.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 660a8a531..d54aefc8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,7 +15,6 @@ pub mod credits; pub mod dialogs; pub mod doc_window; pub mod eyedropper; -pub mod fillui; pub mod icons; pub mod media; pub mod menus; @@ -59,6 +58,7 @@ use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; +pub use panels::fillui; pub use panels::notes; pub use panels::selpane; pub use panels::thumbs; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2aa7577b0..103bf54c3 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1491,6 +1491,7 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec }); } +pub mod fillui; pub mod notes; pub mod selpane; #[cfg(test)] diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/panels/fillui.rs similarity index 100% rename from crates/ui-egui/src/fillui.rs rename to crates/ui-egui/src/panels/fillui.rs From d1022cdaa450e50ef0511b742c908ccf1362a5bd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:02:01 +0000 Subject: [PATCH 28/90] refactor(ui-egui): move animtimeline to panels/animtimeline.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/{ => panels}/animtimeline.rs | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{ => panels}/animtimeline.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d54aefc8f..2b6c480ab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,7 +6,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] -pub mod animtimeline; pub mod backstage; pub mod canvas; pub mod capture; @@ -58,6 +57,7 @@ use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; +pub use panels::animtimeline; pub use panels::fillui; pub use panels::notes; pub use panels::selpane; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 103bf54c3..9df901c05 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -5,7 +5,6 @@ use deckcraft_model::{AnimClass, AnimStart, Fill, ShapeKind}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; -use crate::animtimeline; use crate::icons::{self, Icon}; use crate::ribbon::{cref_param, paint_anim_tile}; use crate::theme::{self, Tokens}; @@ -1498,6 +1497,7 @@ pub mod selpane; #[path = "smartart_tests.rs"] mod smartart_tests; pub mod thumbs; +pub mod animtimeline; #[cfg(test)] #[path = "background_tests.rs"] diff --git a/crates/ui-egui/src/animtimeline.rs b/crates/ui-egui/src/panels/animtimeline.rs similarity index 100% rename from crates/ui-egui/src/animtimeline.rs rename to crates/ui-egui/src/panels/animtimeline.rs From 099dfa7670210a58b8fd5a16880084c66ea02a0b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:02:14 +0000 Subject: [PATCH 29/90] refactor(ui-egui): move the panels test files beside panels.rs --- crates/ui-egui/src/panels.rs | 4 ---- crates/ui-egui/src/{ => panels}/background_tests.rs | 0 crates/ui-egui/src/{ => panels}/pattern_fill_tests.rs | 0 crates/ui-egui/src/{ => panels}/smartart_tests.rs | 0 crates/ui-egui/src/{ => panels}/thesaurus_tests.rs | 0 5 files changed, 4 deletions(-) rename crates/ui-egui/src/{ => panels}/background_tests.rs (100%) rename crates/ui-egui/src/{ => panels}/pattern_fill_tests.rs (100%) rename crates/ui-egui/src/{ => panels}/smartart_tests.rs (100%) rename crates/ui-egui/src/{ => panels}/thesaurus_tests.rs (100%) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9df901c05..5d46268a9 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1494,17 +1494,14 @@ pub mod fillui; pub mod notes; pub mod selpane; #[cfg(test)] -#[path = "smartart_tests.rs"] mod smartart_tests; pub mod thumbs; pub mod animtimeline; #[cfg(test)] -#[path = "background_tests.rs"] mod background_tests; #[cfg(test)] -#[path = "pattern_fill_tests.rs"] mod pattern_fill_tests; #[cfg(test)] @@ -1721,5 +1718,4 @@ fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { } #[cfg(test)] -#[path = "thesaurus_tests.rs"] mod thesaurus_tests; diff --git a/crates/ui-egui/src/background_tests.rs b/crates/ui-egui/src/panels/background_tests.rs similarity index 100% rename from crates/ui-egui/src/background_tests.rs rename to crates/ui-egui/src/panels/background_tests.rs diff --git a/crates/ui-egui/src/pattern_fill_tests.rs b/crates/ui-egui/src/panels/pattern_fill_tests.rs similarity index 100% rename from crates/ui-egui/src/pattern_fill_tests.rs rename to crates/ui-egui/src/panels/pattern_fill_tests.rs diff --git a/crates/ui-egui/src/smartart_tests.rs b/crates/ui-egui/src/panels/smartart_tests.rs similarity index 100% rename from crates/ui-egui/src/smartart_tests.rs rename to crates/ui-egui/src/panels/smartart_tests.rs diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/panels/thesaurus_tests.rs similarity index 100% rename from crates/ui-egui/src/thesaurus_tests.rs rename to crates/ui-egui/src/panels/thesaurus_tests.rs From af61c1cf3af5b9ad06a3b02eb5e2bd40f53c79b1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:56:08 +0000 Subject: [PATCH 30/90] refactor(ui-egui): move preview to canvas/preview.rs --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/{ => canvas}/preview.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => canvas}/preview.rs (100%) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c4d41d9db..8df12f068 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1610,6 +1610,7 @@ fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::Shape } } +pub(super) mod preview; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/canvas/preview.rs similarity index 100% rename from crates/ui-egui/src/preview.rs rename to crates/ui-egui/src/canvas/preview.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2b6c480ab..b6eaa56f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,7 +18,6 @@ pub mod icons; pub mod media; pub mod menus; pub mod opening; -mod preview; pub mod qat; pub mod ribbon; pub mod settings; @@ -54,6 +53,7 @@ mod state; pub use app::key_name; #[cfg(test)] use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; +use canvas::preview; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; From 1368d06eeb5eeab6802b3bc61a32357bf67fab76 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:57:23 +0000 Subject: [PATCH 31/90] refactor(ui-egui): move squiggles to canvas/squiggles.rs --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/{ => canvas}/squiggles.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => canvas}/squiggles.rs (100%) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8df12f068..6a0c46e30 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1611,6 +1611,7 @@ fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::Shape } pub(super) mod preview; +pub mod squiggles; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/canvas/squiggles.rs similarity index 100% rename from crates/ui-egui/src/squiggles.rs rename to crates/ui-egui/src/canvas/squiggles.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b6eaa56f0..e93ddb7d8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,7 +23,6 @@ pub mod ribbon; pub mod settings; pub mod show; pub mod sorter; -pub mod squiggles; pub mod status; pub mod textures; pub mod theme; @@ -54,6 +53,7 @@ pub use app::key_name; #[cfg(test)] use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; use canvas::preview; +pub use canvas::squiggles; pub use clock::{monotonic_secs, now_ms}; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; From ac3c3b7b2a939960c5e2731fa409cc24eccff8b3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:02:22 +0000 Subject: [PATCH 32/90] refactor(ui-egui): move credits to dialogs/credits.rs --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/{ => dialogs}/credits.rs | 0 crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => dialogs}/credits.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38abe13f7..13b73f84d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3538,6 +3538,7 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { close } +pub mod credits; #[cfg(test)] #[path = "font_dialog_tests.rs"] mod font_dialog_tests; diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/dialogs/credits.rs similarity index 100% rename from crates/ui-egui/src/credits.rs rename to crates/ui-egui/src/dialogs/credits.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e93ddb7d8..523ea1769 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,7 +10,6 @@ pub mod backstage; pub mod canvas; pub mod capture; pub mod control; -pub mod credits; pub mod dialogs; pub mod doc_window; pub mod eyedropper; @@ -55,6 +54,7 @@ use app::{OBJECTS_MARKER, system_clipboard_text, web_image_files}; use canvas::preview; pub use canvas::squiggles; pub use clock::{monotonic_secs, now_ms}; +pub use dialogs::credits; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; pub use panels::animtimeline; From a730e66757e19d67e0c38cae3733bf072f63cf4f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:03:36 +0000 Subject: [PATCH 33/90] refactor(ui-egui): move updates_ui to dialogs/updates_ui.rs --- crates/ui-egui/src/dialogs.rs | 2 ++ crates/ui-egui/src/{ => dialogs}/updates_ui.rs | 0 crates/ui-egui/src/lib.rs | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{ => dialogs}/updates_ui.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13b73f84d..5985a8028 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3542,6 +3542,8 @@ pub mod credits; #[cfg(test)] #[path = "font_dialog_tests.rs"] mod font_dialog_tests; +#[cfg(not(target_arch = "wasm32"))] +pub(super) mod updates_ui; #[cfg(test)] #[path = "size_position_tests.rs"] diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs similarity index 100% rename from crates/ui-egui/src/updates_ui.rs rename to crates/ui-egui/src/dialogs/updates_ui.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 523ea1769..076ce171e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,8 +27,6 @@ pub mod textures; pub mod theme; pub mod toast; pub mod uifonts; -#[cfg(not(target_arch = "wasm32"))] -mod updates_ui; pub mod widgets; use std::collections::HashMap; @@ -55,6 +53,8 @@ use canvas::preview; pub use canvas::squiggles; pub use clock::{monotonic_secs, now_ms}; pub use dialogs::credits; +#[cfg(not(target_arch = "wasm32"))] +use dialogs::updates_ui; pub use ime_hooks::install_ime_pass_hooks; pub use panels as panes; pub use panels::animtimeline; From 7ab59a0cd3e4d090a04ff45f7c00e4e7d0a2fb16 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:20:11 +0000 Subject: [PATCH 34/90] refactor(ui-egui): move qat to ribbon/qat.rs --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/src/{ => ribbon}/qat.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename crates/ui-egui/src/{ => ribbon}/qat.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 076ce171e..30b5a0cd5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,7 +17,6 @@ pub mod icons; pub mod media; pub mod menus; pub mod opening; -pub mod qat; pub mod ribbon; pub mod settings; pub mod show; @@ -62,6 +61,7 @@ pub use panels::fillui; pub use panels::notes; pub use panels::selpane; pub use panels::thumbs; +pub use ribbon::qat; pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp}; pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE}; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cedd34c4e..2b5daff22 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -3624,6 +3624,7 @@ mod tests { #[cfg(test)] #[path = "effects_tests.rs"] mod effects_tests; +pub mod qat; #[cfg(test)] #[path = "master_layout_tests.rs"] diff --git a/crates/ui-egui/src/qat.rs b/crates/ui-egui/src/ribbon/qat.rs similarity index 100% rename from crates/ui-egui/src/qat.rs rename to crates/ui-egui/src/ribbon/qat.rs From 8338e21ecf1d47fa4d6da71fce6bfa688cb33255 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:21:31 +0000 Subject: [PATCH 35/90] refactor(ui-egui): move the canvas test files beside canvas.rs --- crates/ui-egui/src/canvas.rs | 2 -- crates/ui-egui/src/{ => canvas}/guides_tests.rs | 0 crates/ui-egui/src/{ => canvas}/ruler_tests.rs | 0 3 files changed, 2 deletions(-) rename crates/ui-egui/src/{ => canvas}/guides_tests.rs (100%) rename crates/ui-egui/src/{ => canvas}/ruler_tests.rs (100%) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6a0c46e30..8200375a9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1616,9 +1616,7 @@ pub mod squiggles; mod tests; #[cfg(test)] -#[path = "ruler_tests.rs"] mod ruler_tests; #[cfg(test)] -#[path = "guides_tests.rs"] mod guides_tests; diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/canvas/guides_tests.rs similarity index 100% rename from crates/ui-egui/src/guides_tests.rs rename to crates/ui-egui/src/canvas/guides_tests.rs diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/canvas/ruler_tests.rs similarity index 100% rename from crates/ui-egui/src/ruler_tests.rs rename to crates/ui-egui/src/canvas/ruler_tests.rs From 742e660afd5cce0fc082b8604affa160ed9585c0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:21:33 +0000 Subject: [PATCH 36/90] refactor(ui-egui): move the dialogs test files beside dialogs.rs --- crates/ui-egui/src/dialogs.rs | 9 --------- crates/ui-egui/src/{ => dialogs}/columns_dialog_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/font_dialog_tests.rs | 0 .../ui-egui/src/{ => dialogs}/paragraph_dialog_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/photo_album_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/preferences_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/size_position_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/spelling_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/theme_colors_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/theme_fonts_tests.rs | 0 10 files changed, 9 deletions(-) rename crates/ui-egui/src/{ => dialogs}/columns_dialog_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/font_dialog_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/paragraph_dialog_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/photo_album_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/preferences_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/size_position_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/spelling_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/theme_colors_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/theme_fonts_tests.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5985a8028..1f50e7f2c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3540,21 +3540,17 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { pub mod credits; #[cfg(test)] -#[path = "font_dialog_tests.rs"] mod font_dialog_tests; #[cfg(not(target_arch = "wasm32"))] pub(super) mod updates_ui; #[cfg(test)] -#[path = "size_position_tests.rs"] mod size_position_tests; #[cfg(test)] -#[path = "columns_dialog_tests.rs"] mod columns_dialog_tests; #[cfg(test)] -#[path = "photo_album_tests.rs"] mod photo_album_tests; /// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden @@ -3617,11 +3613,9 @@ fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result Date: Mon, 26 Oct 2026 20:22:08 +0000 Subject: [PATCH 37/90] refactor(ui-egui): move the ribbon test files beside ribbon.rs --- crates/ui-egui/src/ribbon.rs | 6 ------ crates/ui-egui/src/{ => ribbon}/align_tests.rs | 0 crates/ui-egui/src/{ => ribbon}/drawtable_tests.rs | 0 crates/ui-egui/src/{ => ribbon}/effects_tests.rs | 0 crates/ui-egui/src/{ => ribbon}/icons_popup_tests.rs | 0 crates/ui-egui/src/{ => ribbon}/inktoshape_tests.rs | 0 crates/ui-egui/src/{ => ribbon}/master_layout_tests.rs | 0 7 files changed, 6 deletions(-) rename crates/ui-egui/src/{ => ribbon}/align_tests.rs (100%) rename crates/ui-egui/src/{ => ribbon}/drawtable_tests.rs (100%) rename crates/ui-egui/src/{ => ribbon}/effects_tests.rs (100%) rename crates/ui-egui/src/{ => ribbon}/icons_popup_tests.rs (100%) rename crates/ui-egui/src/{ => ribbon}/inktoshape_tests.rs (100%) rename crates/ui-egui/src/{ => ribbon}/master_layout_tests.rs (100%) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2b5daff22..374457777 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -3622,20 +3622,16 @@ mod tests { } #[cfg(test)] -#[path = "effects_tests.rs"] mod effects_tests; pub mod qat; #[cfg(test)] -#[path = "master_layout_tests.rs"] mod master_layout_tests; #[cfg(test)] -#[path = "icons_popup_tests.rs"] mod icons_popup_tests; #[cfg(test)] -#[path = "align_tests.rs"] mod align_tests; #[cfg(test)] @@ -3658,11 +3654,9 @@ mod title_bar_tests { } #[cfg(test)] -#[path = "drawtable_tests.rs"] mod drawtable_tests; #[cfg(test)] -#[path = "inktoshape_tests.rs"] mod inktoshape_tests; /// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a diff --git a/crates/ui-egui/src/align_tests.rs b/crates/ui-egui/src/ribbon/align_tests.rs similarity index 100% rename from crates/ui-egui/src/align_tests.rs rename to crates/ui-egui/src/ribbon/align_tests.rs diff --git a/crates/ui-egui/src/drawtable_tests.rs b/crates/ui-egui/src/ribbon/drawtable_tests.rs similarity index 100% rename from crates/ui-egui/src/drawtable_tests.rs rename to crates/ui-egui/src/ribbon/drawtable_tests.rs diff --git a/crates/ui-egui/src/effects_tests.rs b/crates/ui-egui/src/ribbon/effects_tests.rs similarity index 100% rename from crates/ui-egui/src/effects_tests.rs rename to crates/ui-egui/src/ribbon/effects_tests.rs diff --git a/crates/ui-egui/src/icons_popup_tests.rs b/crates/ui-egui/src/ribbon/icons_popup_tests.rs similarity index 100% rename from crates/ui-egui/src/icons_popup_tests.rs rename to crates/ui-egui/src/ribbon/icons_popup_tests.rs diff --git a/crates/ui-egui/src/inktoshape_tests.rs b/crates/ui-egui/src/ribbon/inktoshape_tests.rs similarity index 100% rename from crates/ui-egui/src/inktoshape_tests.rs rename to crates/ui-egui/src/ribbon/inktoshape_tests.rs diff --git a/crates/ui-egui/src/master_layout_tests.rs b/crates/ui-egui/src/ribbon/master_layout_tests.rs similarity index 100% rename from crates/ui-egui/src/master_layout_tests.rs rename to crates/ui-egui/src/ribbon/master_layout_tests.rs From 9ab4ed95812ecd47d87c872395230aa381844671 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:22:11 +0000 Subject: [PATCH 38/90] refactor(ui-egui): move the sorter test files beside sorter.rs --- crates/ui-egui/src/sorter.rs | 1 - crates/ui-egui/src/{sorter_tests.rs => sorter/ui_tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/ui-egui/src/{sorter_tests.rs => sorter/ui_tests.rs} (100%) diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 210a802c4..b37061860 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -475,7 +475,6 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { } #[cfg(test)] -#[path = "sorter_tests.rs"] mod ui_tests; /// A slide's outline block height before it has been laid out: title row plus its text lines. diff --git a/crates/ui-egui/src/sorter_tests.rs b/crates/ui-egui/src/sorter/ui_tests.rs similarity index 100% rename from crates/ui-egui/src/sorter_tests.rs rename to crates/ui-egui/src/sorter/ui_tests.rs From d428b4804a60675dc43623bf0faf22d39ab6bf24 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:25:42 +0000 Subject: [PATCH 39/90] refactor(ui-egui): move the backstage test files beside backstage.rs --- crates/ui-egui/src/backstage.rs | 1 - crates/ui-egui/src/{backstage_tests.rs => backstage/tests.rs} | 0 2 files changed, 1 deletion(-) rename crates/ui-egui/src/{backstage_tests.rs => backstage/tests.rs} (100%) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index fd412028a..0c3b11d9b 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1417,5 +1417,4 @@ fn account(app: &mut SlideApp, ui: &mut Ui) { } #[cfg(test)] -#[path = "backstage_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage/tests.rs similarity index 100% rename from crates/ui-egui/src/backstage_tests.rs rename to crates/ui-egui/src/backstage/tests.rs From 462310faedbe335afc5066537418fa476ad354aa Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:29:42 +0000 Subject: [PATCH 40/90] refactor(ui-egui): split canvas.rs into canvas/{input,scroll,ime,media,rulers,guides,overlays,context_menu}.rs --- crates/ui-egui/src/canvas.rs | 1304 +-------------------- crates/ui-egui/src/canvas/context_menu.rs | 282 +++++ crates/ui-egui/src/canvas/guides.rs | 85 ++ crates/ui-egui/src/canvas/ime.rs | 43 + crates/ui-egui/src/canvas/input.rs | 237 ++++ crates/ui-egui/src/canvas/media.rs | 75 ++ crates/ui-egui/src/canvas/overlays.rs | 283 +++++ crates/ui-egui/src/canvas/rulers.rs | 235 ++++ crates/ui-egui/src/canvas/scroll.rs | 46 + 9 files changed, 1312 insertions(+), 1278 deletions(-) create mode 100644 crates/ui-egui/src/canvas/context_menu.rs create mode 100644 crates/ui-egui/src/canvas/guides.rs create mode 100644 crates/ui-egui/src/canvas/ime.rs create mode 100644 crates/ui-egui/src/canvas/input.rs create mode 100644 crates/ui-egui/src/canvas/media.rs create mode 100644 crates/ui-egui/src/canvas/overlays.rs create mode 100644 crates/ui-egui/src/canvas/rulers.rs create mode 100644 crates/ui-egui/src/canvas/scroll.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8200375a9..97b173882 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,32 @@ use serde_json::json; use crate::SlideApp; use crate::theme::{self, Tokens}; +mod context_menu; +mod guides; +mod ime; +mod input; +mod media; +mod overlays; +mod rulers; +mod scroll; + +use context_menu::context_menu; +use guides::{draw_grid, draw_guides, grid_line_step}; +pub use ime::ime_area; +use ime::ime_output; +#[cfg(test)] +use input::resize_cursor; +pub(crate) use input::{page_flick, trackpad_twist}; +use input::{pointer, trackpad_scroll, wheel_slide_target}; +use media::{gif_buttons, media_bar, media_frames}; +use overlays::overlays; +#[cfg(test)] +pub(crate) use overlays::text_marks; +#[cfg(test)] +pub(crate) use rulers::ruler_steps; +use rulers::{ruler, ruler_markers}; +use scroll::{scroll_bar, visible_region}; + /// Slide-to-screen mapping. #[derive(Clone, Copy, Debug)] pub struct Xf { @@ -35,39 +61,6 @@ fn mods(ui: &Ui) -> Mods { ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl }) } -/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; -/// other platforms leave the delta at zero. -/// -/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom -/// and two-finger scrolling are left as they are. -pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { - if ctx.will_discard() { - // Multi-pass layout runs this function again with the input already consumed. Acting on - // the discarded pass would start the twist and the replay would immediately end it, - // splitting one gesture into two undo steps. - return; - } - let delta = ctx.input(|i| i.rotation_delta()); - let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; - let turning = delta.abs() > 1e-4; - let blocked = app.show.is_some() - || app.backstage_open() - || app.dialog.as_ref().is_some_and(|d| d.modal()) - || app.palette.is_some() - || app.session.tool.dragging(); - let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); - let rotatable = app.session.active().is_some_and(|d| { - d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) - }); - if !blocked && turning && rotatable && (app.twisting || over) { - let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); - app.twisting = true; - } else if app.twisting { - let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); - app.twisting = false; - } -} - /// The slide's rect on screen for the current zoom. pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let Some(d) = app.session.active() else { return (avail, 1.0) }; @@ -90,59 +83,6 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } -/// A trackpad flick or mouse-wheel notch that should turn the page. -/// -/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative -/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — -/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. -pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { - const MIN_FLICK: f32 = 18.0; - let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; - (along.abs() > MIN_FLICK).then_some(along < 0.0) -} - -/// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, -/// including several notches delivered in one frame, and clamp at each end of the deck. -/// -/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so -/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction -/// drops what was left over. Returns the target, `None` when no wheel event was read. -fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> Option { - let last = slides.checked_sub(1)?; - let mut target = current.min(last); - let mut handled = false; - for event in events { - if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event - && matches!(unit, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page) - && !modifiers.ctrl - && !modifiers.command - && !modifiers.shift - && delta.y.is_finite() - && delta.y.abs() > 0.0 - && delta.y.abs() >= delta.x.abs() - { - if *carry * delta.y < 0.0 { - *carry = 0.0; - } - *carry += delta.y; - // The small slack keeps sums such as 10 × 0.1 from falling just short of a line. - let whole = (carry.abs() + 1e-4).floor(); - *carry -= whole.copysign(*carry); - let steps = whole.min(slides as f32) as usize; - target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; - handled = true; - } - } - handled.then_some(target) -} - -fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { - events.iter().fold(egui::Vec2::ZERO, |sum, event| match event { - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } if !modifiers.ctrl && !modifiers.command => sum + *delta, - _ => sum, - }) -} - /// Paint the editing canvas, including rulers, selection handles, overlays, and current /// zoom/scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { @@ -389,172 +329,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } } -/// Thickness of the canvas scroll bars, in points. -const SCROLL_BAR: f32 = 10.0; - -/// The canvas scroll bar along `axis` (0 across, 1 down): its track, its thumb for `offset`, and -/// how far the thumb can travel. `None` when the slide fits that way. `inset` keeps the rulers' -/// strips clear. -fn scroll_bar(avail: Rect, inset: f32, overflow: egui::Vec2, offset: egui::Vec2, axis: usize) -> Option<(Rect, Rect, f32)> { - let over = overflow[axis]; - if !over.is_finite() || over <= 0.0 { - return None; - } - // Both bars showing leave the corner between them empty. - let corner = if overflow[1 - axis] > 0.0 { SCROLL_BAR } else { 0.0 }; - let track = if axis == 0 { - Rect::from_min_max(pos2(avail.min.x + inset, avail.max.y - SCROLL_BAR), pos2(avail.max.x - corner, avail.max.y)) - } else { - Rect::from_min_max(pos2(avail.max.x - SCROLL_BAR, avail.min.y + inset), pos2(avail.max.x, avail.max.y - corner)) - }; - let len = track.size()[axis].max(0.0); - let view = avail.size()[axis]; - let thumb_len = (len * view / (view + over)).max(SCROLL_BAR * 2.0).min(len); - let travel = (len - thumb_len).max(0.0); - let at = travel * (offset[axis] / over).clamp(0.0, 1.0); - let mut thumb = track; - thumb.min[axis] = track.min[axis] + at; - thumb.max[axis] = thumb.min[axis] + thumb_len; - Some((track, thumb, travel)) -} - -/// 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, - } -} - -/// Where an input method composes for the slide (#101), on screen: at the insertion point while -/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is -/// nothing to type into. -pub fn ime_area(app: &SlideApp, xf: Xf) -> Option { - let st = app.session.active()?; - if let Some(ts) = &st.selection.text { - if ts.notes { - return None; - } - if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0))); - } - let sh = st.shape(ts.shape)?; - return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))); - } - if st.selection.shapes.len() != 1 { - return None; - } - let sh = st.shape(*st.selection.shapes.first()?)?; - if sh.is_line() { - return None; - } - Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))) -} - -/// Turn the input method on for the slide while it takes typing, with its candidate window at -/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types -/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own. -/// -/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed -/// text, owner and cause once at the end of the pass, after every IME event and editor command -/// has settled. -fn ime_output(app: &mut SlideApp, ui: &Ui, xf: Xf) { - let ctx = ui.ctx(); - if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() { - return; - } - let Some(r) = ime_area(app, xf) else { return }; - let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default(); - app.ime_pending = Some(to_global * r); -} - -/// 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 }; - if d.selection.text.is_some() || app.session.tool.dragging() { - return; - } - let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None }; - let pointer = ui.input(|i| i.pointer.hover_pos()); - let mut toggle = None; - for g in gifs { - let r = screen_rect(xf, &g.rect); - if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) { - continue; - } - let b = crate::media::gif_button_rect(r); - // Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF). - let resp = ui.interact(b, ui.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" })); - crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered()); - if resp.clicked() { - toggle = Some(g.id); - } - } - if let Some(id) = toggle { - let _ = app.run("media.gifPlay", json!({"id": id})); - } -} - /// Screen rect of a slide-space box (rotation ignored). fn screen_rect(xf: Xf, x: &Xfrm) -> Rect { Rect::from_min_max(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))) } -/// Video playing in the editor: its current frame over the poster. -fn media_frames(app: &SlideApp, painter: &egui::Painter, xf: Xf) { - let Some(d) = app.session.active() else { return }; - if d.selection.target != Target::Slides || !app.media.any_active() { - return; - } - for (id, x, clip) in crate::media::slide_media(&d.doc, d.selection.slide) { - if clip.video { - crate::media::paint_frame(painter, &app.media, id, screen_rect(xf, &x), clip.crop); - } - } -} - -/// The play/pause and timeline bar under a selected audio or video (PowerPoint shows it on -/// selection or hover). -fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { - let Some(d) = app.session.active() else { return }; - let uid = d.uid; - if d.selection.target != Target::Slides || d.selection.text.is_some() || app.session.tool.dragging() { - return; - } - let pointer = ui.input(|i| i.pointer.hover_pos()); - let media = crate::media::slide_media(&d.doc, d.selection.slide); - let pick = media.iter().find(|(id, _, _)| d.selection.shapes.len() == 1 && d.selection.shapes[0] == *id).or_else(|| { - media.iter().find(|(id, x, _)| { - let r = screen_rect(xf, x); - app.media.status(*id).is_some() || pointer.is_some_and(|p| r.expand2(vec2(0.0, 40.0)).contains(p)) - }) - }); - let Some((id, x, clip)) = pick.cloned() else { return }; - let doc = d.doc.clone(); - let r = screen_rect(xf, &x); - let w = r.width().max(230.0); - let bar = Rect::from_min_size(pos2(r.center().x - w / 2.0, r.max.y + 6.0), vec2(w, 26.0)); - let status = app.media.status(id); - match crate::media::control_bar(ui, bar, status.as_ref(), &clip) { - Some(crate::media::BarAction::Toggle) => app.media.toggle(uid, &doc, id, &clip, crate::media::Owner::Editor), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(uid, &doc, id, &clip, t, crate::media::Owner::Editor), - Some(crate::media::BarAction::Nudge(dt)) => { - let pos = status.map(|s| s.position).unwrap_or(0.0); - app.media.seek(uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); - } - None => {} - } -} - fn master_tex( ctx: &egui::Context, doc: &deckcraft_model::Presentation, @@ -579,1037 +358,6 @@ fn master_tex( Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } -/// The ruler's numbered step in points and the ticks per step: quarter inches, or half -/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a -/// tick per point or pixel would be a solid bar. -pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) { - match unit { - deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2), - _ => (72.0, 4), - } -} - -fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, t: &Tokens) { - let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0)); - let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y)); - p.rect_filled(top, CornerRadius::ZERO, t.card); - p.rect_filled(left, CornerRadius::ZERO, t.card); - let (step, sub) = ruler_steps(unit); - let tick = step as f32 * scale / sub as f32; - let len = |k: u32| { - if k.is_multiple_of(sub) { - 8.0 - } else if k.is_multiple_of(2) { - 5.0 - } else { - 3.0 - } - }; - let mut k = 0; - loop { - let x = slide.min.x + k as f32 * tick; - if x > slide.max.x { - break; - } - let h = len(k); - p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); - if k.is_multiple_of(sub) { - p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim); - } - k += 1; - } - let mut k = 0; - loop { - let y = slide.min.y + k as f32 * tick; - if y > slide.max.y { - break; - } - let w = len(k); - p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint)); - k += 1; - } -} - -/// What is being dragged on the ruler. -#[derive(Clone, Copy, Debug, PartialEq)] -enum RulerDrag { - /// First-line indent (the top triangle). - First, - /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. - Hanging, - /// Left indent (the box under the hanging triangle): moves both together. - Left, - /// The tab stop that was at this position (pt from the text area's left). - Tab(f64), -} - -/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, -/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the -/// ruler removes it. -fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { - if app.session.active().is_none_or(|d| d.selection.text.is_none()) { - return; - } - let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; - if info["rotated"].as_bool().unwrap_or(true) { - return; - } - let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); - let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); - let tabs: Vec<(f64, String)> = info["tabs"] - .as_array() - .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) - .unwrap_or_default(); - let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); - let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; - let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; - // The text area's span, outlined on the ruler. - let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); - p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); - - let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler")); - let id = ui.scope_id().with("ruler_drag"); - let mut drag: Option = ui.data(|d| d.get_temp::(id)); - let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - if resp.drag_started() - && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; - let y = at.y - top.min.y; - drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { - Some(RulerDrag::Tab(*pos)) - } else if y < 6.0 && near(ml + ind) { - Some(RulerDrag::First) - } else if (6.0..11.0).contains(&y) && near(ml) { - Some(RulerDrag::Hanging) - } else if y >= 11.0 && near(ml) { - Some(RulerDrag::Left) - } else { - None - }; - ui.data_mut(|d| match drag { - Some(v) => { - d.insert_temp(id, v); - } - None => { - d.remove::(id); - } - }); - } - // Where each marker is drawn: the dragged one follows the pointer. - let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); - let (mut first, mut left) = (ml + ind, ml); - match live { - Some((RulerDrag::First, q, _)) => first = q, - Some((RulerDrag::Hanging, q, _)) => left = q, - Some((RulerDrag::Left, q, _)) => { - first = q + ind; - left = q; - } - _ => {} - } - let col = t.text_dim; - let tri = |x: f32, y0: f32, down: bool| { - let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; - p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); - }; - tri(sx(first), top.min.y + 1.0, true); - tri(sx(left), top.min.y + 6.0, false); - p.rect( - Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), - CornerRadius::ZERO, - t.card, - Stroke::new(1.0, col), - egui::StrokeKind::Inside, - ); - for (pos, align) in &tabs { - let (q, gone) = match live { - Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), - _ => (*pos, false), - }; - if gone { - continue; - } - tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); - } - // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click - // on the ruler then sets that kind of stop. - let kind_id = ui.scope_id().with("ruler_tab_kind"); - let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); - let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); - let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click()); - const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; - if cr.clicked() { - kind = (kind + 1) % KINDS.len(); - ui.data_mut(|d| d.insert_temp(kind_id, kind)); - } - let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; - cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label)); - let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); - p.rect_stroke( - corner.shrink(2.0), - CornerRadius::same(2), - Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), - egui::StrokeKind::Inside, - ); - tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); - if drag.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } - - // Commit on release; a click on bare ruler adds a left tab. - if resp.drag_stopped() { - if let (Some(d), Some(at)) = (drag, pointer) { - let q = to_q(at.x); - let r = match d { - RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), - RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), - RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), - RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), - RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), - }; - if let Err(e) = r { - app.set_status(e); - } - } - ui.data_mut(|d| d.remove::(id)); - } else if resp.clicked() - && let Some(at) = resp.interact_pointer_pos() - && at.x >= area.min.x - && at.x <= area.max.x - { - let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); - if resp.double_clicked() || near_tab { - // A double-click on a stop opens the Tabs dialog, as in PowerPoint. - if resp.double_clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("tabs")); - } - } else { - let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); - } - } -} - -/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. -fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { - let (x, y) = (at.x, at.y); - let st = Stroke::new(1.5, col); - p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); - match align { - "r" => { - p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); - } - "ctr" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - } - "dec" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); - } - _ => { - p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); - } - } -} - -/// The presentation's drawing guides; the one being dragged at its new place, labelled with its -/// distance from the slide's centre in points. -fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { - let Some(st) = app.session.active() else { return }; - let size = st.doc.slide_size; - let drag = app.session.tool.guide_drag; - for (i, g) in st.doc.guides.iter().enumerate() { - let (pos, active) = match drag { - Some((di, p)) if di == i => (p, true), - _ => (g.pos, app.session.tool.guide_hover == Some(i)), - }; - let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; - let stroke = Stroke::new(1.0, col); - let (a, b) = if g.vertical { - let x = xf.to_screen(Point::new(pos, 0.0)).x; - (pos2(x, slide.min.y), pos2(x, slide.max.y)) - } else { - let y = xf.to_screen(Point::new(0.0, pos)).y; - (pos2(slide.min.x, y), pos2(slide.max.x, y)) - }; - // Dashed, like PowerPoint's guides. - let len = (b - a).length(); - let dir = (b - a) / len.max(1.0); - let mut d = 0.0; - while d < len { - painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); - d += 7.0; - } - if drag.is_some_and(|(di, _)| di == i) { - let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; - let off = pos - centre; - let side = match (g.vertical, off < 0.0, off > 0.0) { - (true, true, _) => " left of centre", - (true, _, true) => " right of centre", - (false, true, _) => " above centre", - (false, _, true) => " below centre", - _ => " (centre)", - }; - let label = format!("{:.0} pt{side}", off.abs()); - let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; - let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); - let r = Rect::from_min_size(at, galley.size()).expand(3.0); - painter.rect_filled(r, CornerRadius::same(3), t.card); - painter.galley(at, galley, t.text); - } - } -} - -fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { - if crate::eyedropper::canvas(app, ui, resp, xf) { - return; - } - if app.anim_preview_active() { - // A click on the slide ends the preview, then selects as usual. - if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { - app.stop_anim_preview(); - } else { - return; - } - } - let tol = (4.0 / xf.scale) as f64; - let m = mods(ui); - let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; - // The rulers have their own controls; a press there mustn't reach the slide (and end text - // editing). A drag already under way keeps going over them. - if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { - return; - } - let p = xf.to_slide(pos); - let pressure = reported_pressure(ui); - let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; - let primary = ui.input(|i| i.pointer.primary_pressed()); - if resp.hovered() && !app.session.tool.dragging() { - let _ = app.session.pointer(ev(PointerKind::Move)); - } - let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary)); - let r = if secondary && !app.session.tool.dragging() { - // Select what's under the pointer before the context menu opens (on release). - app.session.pointer(ev(PointerKind::ContextClick)) - } else if resp.triple_clicked() { - app.session.pointer(ev(PointerKind::TripleClick)) - } else if resp.double_clicked() { - app.session.pointer(ev(PointerKind::DoubleClick)) - } else if primary && resp.hovered() && !resp.secondary_clicked() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) { - app.session.pointer(ev(PointerKind::Down)) - } else if resp.dragged_by(egui::PointerButton::Primary) || (app.session.tool.dragging() && ui.input(|i| i.pointer.primary_down())) { - app.session.pointer(ev(PointerKind::Drag)) - } else if app.session.tool.dragging() && ui.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { - app.session.pointer(ev(PointerKind::Up)) - } else { - Ok(serde_json::Value::Null) - }; - if let Err(e) = r { - app.set_status(e.to_string()); - } - // Cursor. The Format and Animation Painters override everything: their whole point is to - // tell the user the tool is armed and waiting for a click (#830). - let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some(); - let cursor = if painter_armed { - egui::CursorIcon::Copy - } else { - match (&app.session.tool.kind, &app.session.tool.hover) { - (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, - (ToolKind::TextBox, _) => egui::CursorIcon::Text, - (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, - (_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle), - (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, - (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, - (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, - (_, Some(Hit::Shape { text: true, .. })) => { - if app.session.active().is_some_and(|d| d.selection.text.is_some()) { - egui::CursorIcon::Text - } else { - egui::CursorIcon::Move - } - } - (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, - _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { - Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, - Some(_) => egui::CursorIcon::ResizeVertical, - None => egui::CursorIcon::Default, - }, - } - }; - if resp.hovered() { - ui.ctx().set_cursor_icon(cursor); - } - if app.session.tool.dragging() { - ui.ctx().request_repaint(); - } -} - -fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { - for i in 0..4 { - p.line_segment([pts[i], pts[(i + 1) % 4]], stroke); - } -} - -fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { - // Spelling squiggles under misspelt words (checked in the background). - if let Some(st) = app.session.active() { - let lines = app.squiggles.update(st, Some(ui.ctx())); - let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); - for (a, b) in lines { - crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); - } - } - let Some(st) = app.session.active() else { return }; - let sel_stroke = Stroke::new(1.0, t.selection); - let editing = st.selection.text.clone(); - for id in &st.selection.shapes { - let Some(sh) = st.shape(*id) else { continue }; - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let a = x.affine(); - let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q)); - if sh.is_line() { - let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))); - let glued = match sh.kind { - deckcraft_model::ShapeKind::Connector { start, end } => [start.is_some(), end.is_some()], - _ => [false, false], - }; - for (q, g) in [p0, p1].into_iter().zip(glued) { - // Glued ends are filled with the accent, like attached connector ends. - let fill = if g { t.accent } else { t.handle_fill }; - painter.circle(q, 4.5, fill, Stroke::new(1.0, if g { t.handle_fill } else { t.handle_stroke })); - } - continue; - } - let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes); - if app.session.tool.editing_points(st) == Some(sh.id) { - // Edit Points: the outline's vertices are the handles. - // No selection box: the outline and its vertices are what's edited. - if let Some((path, index)) = app.session.tool.edit_vertex - && let Some((hin, hout, v)) = deckcraft_engine::cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) - { - for h in [hin, hout].into_iter().flatten() { - painter.line_segment([xf.to_screen(v), xf.to_screen(h)], sel_stroke); - painter.rect( - egui::Rect::from_center_size(xf.to_screen(h), egui::vec2(6.0, 6.0)), - 0.0, - Color32::WHITE, - Stroke::new(1.0, t.selection), - egui::StrokeKind::Middle, - ); - } - } - for q in deckcraft_engine::cmd::points::slide_vertices(&st.doc, &st.selection, sh).into_iter().flatten() { - let c = xf.to_screen(q); - painter.rect( - egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)), - 0.0, - Color32::BLACK, - Stroke::new(1.0, Color32::WHITE), - egui::StrokeKind::Middle, - ); - } - continue; - } - if editing_this { - // Dashed box while editing text. - for i in 0..4 { - let (s, e) = (corners[i], corners[(i + 1) % 4]); - painter.add(egui::Shape::dashed_line(&[s, e], Stroke::new(1.0, t.selection), 4.0, 3.0)); - } - } else { - poly(painter, corners, sel_stroke); - } - if sh.locked && !editing_this { - // Locked: greyed handles that don't respond, no rotate or adjust handles. - for h in box_handles(&x).0 { - painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill)); - } - continue; - } - if st.selection.shapes.len() == 1 || !editing_this { - let (hs, rot) = box_handles(&x); - let rot_s = xf.to_screen(rot); - if !editing_this && sh.ph.as_ref().is_none_or(|_| true) { - let top_mid = xf.to_screen(hs[1]); - painter.line_segment([top_mid, rot_s], sel_stroke); - rotate_handle(painter, rot_s, t); - } - for h in hs { - painter.circle(xf.to_screen(h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); - } - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - for hd in &geo.handles { - let q = xf.to_screen(a * hd.pos); - painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); - } - for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { - let q = xf.to_screen(a * hd.pos); - painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); - } - } - } - // Caret / text selection, in a shape or a table cell. - if let Some(ts) = &editing { - for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { - painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE)); - } - // An input method's composition: underlined in place, thicker under the part being converted. - for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { - painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret)); - } - if !ts.is_range() { - let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); - // An empty placeholder shows the caret where typing begins too. - if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - // In the colour of the text being typed: a black caret is lost on a dark slide. - let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32); - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color)); - } - } - } - // Table cell editing: outline the cell (merged cells as drawn). - if let Some(ts) = &editing - && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) - && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) - && let Some(cr) = f.cell_rect(r, c) - { - let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] - .map(|q| xf.to_screen(f.affine * q)); - poly(painter, pts, Stroke::new(2.0, t.accent)); - } - // Shade the selected table cells in the text-selection color, each merged cell as it is drawn. - if let Some((id, ..)) = st.selection.cells - && st.selection.text.is_none() - && let Some(sh) = st.shape(id) - && let deckcraft_model::ShapeKind::Table(table) = &sh.kind - && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) - { - use deckcraft_engine::cmd::table::{in_range, selected_range}; - let range = selected_range(st.selection.cells, id, table); - for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) { - let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)]; - painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); - } - } - // Smart guides, marquee, previews. - for g in &app.session.tool.guides { - let (a, b) = - if g.vertical { (Point::new(g.pos, g.from), Point::new(g.pos, g.to)) } else { (Point::new(g.from, g.pos), Point::new(g.to, g.pos)) }; - painter.add(egui::Shape::dashed_line(&[xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, t.guide), 5.0, 3.0)); - } - if let Some(m) = app.session.tool.marquee { - let r = Rect::from_two_pos(xf.to_screen(Point::new(m.x0, m.y0)), xf.to_screen(Point::new(m.x1, m.y1))); - painter.rect( - r, - CornerRadius::ZERO, - Color32::from_rgba_unmultiplied(80, 130, 230, 30), - Stroke::new(1.0, Color32::from_rgb(80, 130, 230)), - egui::StrokeKind::Inside, - ); - } - if let Some(pv) = app.session.tool.preview { - draw_preview(app, painter, xf, pv, t); - } - // Freeform being drawn. - let path = &app.session.tool.path_preview; - if path.len() > 1 { - let pts: Vec = if app.session.tool.path_smooth && path.len() > 2 { - let d = deckcraft_engine::cmd::shape::freeform_path(path, false, true); - let mut v = vec![]; - let bp = deckcraft_render::parse_path(&d); - deckcraft_geom::preset_flatten(&bp, &mut |el| match el { - deckcraft_geom::PathEl::MoveTo(q) | deckcraft_geom::PathEl::LineTo(q) => v.push(q), - _ => {} - }); - v - } else { - path.clone() - }; - painter.add(egui::Shape::line(pts.iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.5, t.accent))); - if app.session.tool.path_closing - && let Some(s0) = path.first() - { - painter.circle(xf.to_screen(*s0), 5.0, t.accent, Stroke::new(1.5, t.handle_fill)); - } - } - // Connection sites of the shape under a connector end, and the site it would glue to. - for q in &app.session.tool.sites { - let c = xf.to_screen(*q); - painter.circle(c, 3.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); - } - if let Some(g) = app.session.tool.glue { - painter.circle(xf.to_screen(g), 5.0, t.accent, Stroke::new(1.5, t.handle_fill)); - } - if app.session.tool.ink_preview.len() > 1 - && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind - { - let pts = &app.session.tool.ink_preview; - let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); - let c = theme::to_color32(*color); - let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; - if legacy || mode == "highlighter" { - // One line, so see-through highlighter ink doesn't darken where segments meet. - let line: Vec = pts.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); - painter.add(egui::Shape::line(line, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); - } else { - for pair in pts.windows(2) { - let [q0, q1] = pair else { continue }; - let a = deckcraft_engine::model::ink_sample_width(*width, q0.2, legacy); - let b = deckcraft_engine::model::ink_sample_width(*width, q1.2, legacy); - let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; - let p0 = xf.to_screen(Point::new(q0.0, q0.1)); - let p1 = xf.to_screen(Point::new(q1.0, q1.1)); - painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); - } - } - } -} - -/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. -fn reported_pressure(ui: &Ui) -> Option { - ui.input(|i| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::Touch { force: Some(f), phase, .. } - if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => - { - Some(f.clamp(0.0, 1.0)) - } - _ => None, - }) - }) -} - -/// The text selection (quads) of the text being edited, in slide space: in a shape or a table -/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. -pub(crate) struct TextMarks { - pub selection: Vec<[Point; 4]>, -} - -pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { - let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; - let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; - let (s0, s1) = ts.ordered(); - let selection = l - .selection_rects(pos(s0), pos(s1)) - .into_iter() - .map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q)) - .collect(); - Some(TextMarks { selection }) -} - -fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { - p.circle_filled(c, 6.0, t.handle_fill); - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: (0..=20) - .map(|i| { - let a = -0.4 + i as f32 / 20.0 * 5.2; - pos2(c.x + 4.0 * a.cos(), c.y + 4.0 * a.sin()) - }) - .collect(), - closed: false, - fill: Color32::TRANSPARENT, - stroke: Stroke::new(1.3, t.handle_stroke).into(), - })); -} - -fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) { - let preset = match &app.session.tool.kind { - ToolKind::Shape { preset } => preset.clone(), - _ => "rect".into(), - }; - if preset == deckcraft_engine::tools::DRAW_TABLE { - // A border inside a table (flat), or the outline of a new table. - let (a, b) = (xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); - if x.w < 0.5 || x.h < 0.5 { - p.line_segment([a, b], Stroke::new(1.5, t.accent)); - } else { - p.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - } - return; - } - if deckcraft_geom::preset::is_line_like(&preset) { - let a = x.affine(); - p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent)); - return; - } - let r = Rect::from_two_pos(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); - if r.width() < 1.0 || r.height() < 1.0 { - return; - } - crate::ribbon::paint_preset(p, r, &preset, t.accent.gamma_multiply(0.25), t.accent); -} - -fn context_menu(app: &mut SlideApp, resp: &egui::Response) { - // Right-clicking a vertex in Edit Points picks it for the point menu. - if resp.secondary_clicked() - && let Some(Hit::Vertex { path, index, .. }) = app.session.tool.hover.clone() - { - app.session.tool.edit_vertex = Some((path, index)); - } - resp.context_menu(|ui| { - let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); - if let Some(sh) = editing { - if let Some((path, index)) = app.session.tool.edit_segment { - let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); - for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); - ui.close(); - } - } - // Each of these renumbers the points, so the picked segment is dropped. - if ui.button("Add Point").clicked() { - let t = app.session.tool.edit_segment_t.unwrap_or(0.5); - let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); - app.session.tool.edit_segment = None; - ui.close(); - } - let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index); - if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { - let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); - app.session.tool.edit_segment = None; - ui.close(); - } - // Open Path opens the outline at the segment's start point. - let (label, id, at) = match closed { - Some(false) => ("Close Path", "shape.closePath", index), - _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": at})); - app.session.tool.edit_segment = None; - ui.close(); - } - ui.separator(); - } else if let Some((path, index)) = app.session.tool.edit_vertex { - let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); - for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); - ui.close(); - } - } - if ui.button("Delete Point").clicked() { - let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); - app.session.tool.edit_vertex = None; - ui.close(); - } - let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { - Some(false) => ("Close Path", "shape.closePath"), - _ => ("Open Path", "shape.openPath"), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": index})); - // The points are numbered from the new start now. - app.session.tool.edit_vertex = None; - ui.close(); - } - ui.separator(); - } - if ui.button("Exit Edit Points").clicked() { - let _ = app.run("shape.editPoints", json!({"on": false})); - ui.close(); - } - return; - } - // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. - if let Some(w) = app.squiggles.menu_word.clone() { - if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { - let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); - // The gap is spaces; take it from the text so tabs or no-break spaces match. - let gap = app - .session - .active() - .and_then(|st| match w.tag { - crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), - crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { - deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), - _ => None, - }, - crate::squiggles::Tag::Notes { .. } => None, - }) - .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) - .unwrap_or(cut); - let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); - if let Some(o) = p.as_object_mut() { - match w.tag { - crate::squiggles::Tag::Shape { id, para } => { - o.insert("shape".into(), json!(id)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Cell { id, row, col, para } => { - o.insert("shape".into(), json!(id)); - o.insert("row".into(), json!(row)); - o.insert("col".into(), json!(col)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Notes { .. } => {} - } - } - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); - } - for to in &w.suggestions { - if ui.button(egui::RichText::new(to).strong()).clicked() { - let p = match w.tag { - crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), - crate::squiggles::Tag::Cell { id, row, col, para } => { - json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) - } - crate::squiggles::Tag::Notes { .. } => json!({}), - }; - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } - if ui.button("Ignore All").clicked() { - app.squiggles.ignore(&w.word); - app.squiggles.menu_word = None; - ui.close(); - } - if ui.button("Add to Dictionary").clicked() { - if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { - app.set_status(e.to_string()); - } - app.squiggles.menu_word = None; - ui.close(); - } - ui.separator(); - } - let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); - for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { - if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - ui.separator(); - // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. - let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); - if editing { - if ui.button("Paragraph…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); - ui.close(); - } - for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { - if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - if ui.button("Link…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - ui.separator(); - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } - } else if has_sel { - if ui.button("Edit Text").clicked() { - let _ = app.run("text.edit", json!({})); - ui.close(); - } - if crate::ribbon::enabled(app, "shape.editPoints") - && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) - && ui.button("Edit Points").clicked() - { - let _ = app.run("shape.editPoints", json!({"on": true})); - ui.close(); - } - if ui.button("Font…").clicked() { - let _ = app.run("format.fontDialog", json!({})); - ui.close(); - } - if ui.button("Paragraph…").clicked() { - let _ = app.run("format.paragraphDialog", json!({})); - ui.close(); - } - ui.menu_button("Bring to Front", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); - }); - ui.menu_button("Send to Back", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); - }); - ui.menu_button("Group", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - }); - let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); - if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { - let _ = app.run("shape.lock", json!({"locked": !locked})); - ui.close(); - } - let picture = app - .session - .active() - .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); - if picture && ui.button("Change Picture…").clicked() { - let _ = app.run("app.changePictureDialog", json!({})); - ui.close(); - } - if ui.button("Link…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - if ui.button("Edit Alt Text…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("altText")); - ui.close(); - } - if ui.button("Set as Default Shape").clicked() { - let _ = app.run("shape.setDefault", json!({})); - ui.close(); - } - if ui.button("Size and Position…").clicked() { - let _ = app.run("shape.sizePosition", json!({})); - ui.close(); - } - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } - } else { - ui.menu_button("Layout", |ui| { - let layouts: Vec<(String, u32)> = app - .session - .active() - .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) - .unwrap_or_default(); - for (n, id) in layouts { - if ui.button(n).clicked() { - let _ = app.run("slide.layout", json!({"layout": id})); - ui.close(); - } - } - }); - ui.menu_button("Grid and Guides", |ui| { - let mut on = app.ui.guides; - if ui.checkbox(&mut on, "Guides").clicked() { - let _ = app.run("view.guides", json!({"on": on})); - } - for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { - if ui.button(label).clicked() { - let _ = app.run("guide.add", json!({"vertical": vertical})); - let _ = app.run("view.guides", json!({"on": true})); - ui.close(); - } - } - }); - if ui.button("Reset Slide").clicked() { - let _ = app.run("slide.reset", json!({})); - ui.close(); - } - if ui.button("Format Background…").clicked() { - let _ = app.run("view.pane", json!({"pane": "background"})); - ui.close(); - } - if ui.button("New Comment").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("comment")); - ui.close(); - } - } - }); -} - -/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; -/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of -/// lines (bad inputs, or so fine that a line would land on every pixel). -fn grid_line_step(spacing: f64, scale: f32) -> Option { - if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { - return None; - } - let step = (spacing as f32) * scale; - if !step.is_finite() || step < 1.0 { - return None; - } - Some(step) -} - -fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { - let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); - // A denser grid than this paints a grey slab and costs a frame. Skip it. - if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { - return; - } - let mut x = slide.min.x + step; - while x < slide.max.x { - painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); - x += step; - } - let mut y = slide.min.y + step; - while y < slide.max.y { - painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); - y += step; - } -} - -fn grid_fits(span: f32, step: f32) -> bool { - span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 -} - -/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the -/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the -/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping -/// reduces to the classic NW/NE/E/SE/S/SW/W assignment. -fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon { - // The resize handles are drawn from the shape's effective box (a placeholder inherits it - // from its layout), so the cursor follows the same one. - let Some(d) = session.active() else { return egui::CursorIcon::Default }; - let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default }; - let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh); - // The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward - // the handle. Corners resize along the diagonal, edge handles along their edge. The - // direction is normalised - the diagonal at 45 degrees whatever the shape's proportions - - // so a wide rectangle still shows a diagonal cursor on its corners. - let (dx, dy): (f64, f64) = match handle { - 0 => (-1.0, -1.0), - 1 => (0.0, -1.0), - 2 => (1.0, -1.0), - 3 => (1.0, 0.0), - 4 => (1.0, 1.0), - 5 => (0.0, 1.0), - 6 => (-1.0, 1.0), - _ => (-1.0, 0.0), - }; - // Map through the linear part of the transform (rotation, flips): two points along the - // axis, the difference is the slide-space direction. - let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0); - let a = x.affine(); - let p0 = a * centre; - let p1 = a * Point::new(centre.x + dx, centre.y + dy); - let v = p1 - p0; - if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) { - return egui::CursorIcon::Default; - } - // Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down - // (vertical), 135° = down-left (NE-SW). Pick the closest of the four. - let mut deg = v.y.atan2(v.x).to_degrees(); - if deg < 0.0 { - deg += 180.0; - } - if (22.5..67.5).contains(°) { - egui::CursorIcon::ResizeNwSe - } else if (67.5..112.5).contains(°) { - egui::CursorIcon::ResizeVertical - } else if (112.5..157.5).contains(°) { - egui::CursorIcon::ResizeNeSw - } else { - egui::CursorIcon::ResizeHorizontal - } -} - pub(super) mod preview; pub mod squiggles; #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs new file mode 100644 index 000000000..260838ebb --- /dev/null +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -0,0 +1,282 @@ +use super::*; + +pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { + // Right-clicking a vertex in Edit Points picks it for the point menu. + if resp.secondary_clicked() + && let Some(Hit::Vertex { path, index, .. }) = app.session.tool.hover.clone() + { + app.session.tool.edit_vertex = Some((path, index)); + } + resp.context_menu(|ui| { + let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); + if let Some(sh) = editing { + if let Some((path, index)) = app.session.tool.edit_segment { + let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); + for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); + ui.close(); + } + } + // Each of these renumbers the points, so the picked segment is dropped. + if ui.button("Add Point").clicked() { + let t = app.session.tool.edit_segment_t.unwrap_or(0.5); + let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); + app.session.tool.edit_segment = None; + ui.close(); + } + let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index); + if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { + let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); + app.session.tool.edit_segment = None; + ui.close(); + } + // Open Path opens the outline at the segment's start point. + let (label, id, at) = match closed { + Some(false) => ("Close Path", "shape.closePath", index), + _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": at})); + app.session.tool.edit_segment = None; + ui.close(); + } + ui.separator(); + } else if let Some((path, index)) = app.session.tool.edit_vertex { + let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); + for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); + ui.close(); + } + } + if ui.button("Delete Point").clicked() { + let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); + app.session.tool.edit_vertex = None; + ui.close(); + } + let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { + Some(false) => ("Close Path", "shape.closePath"), + _ => ("Open Path", "shape.openPath"), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": index})); + // The points are numbered from the new start now. + app.session.tool.edit_vertex = None; + ui.close(); + } + ui.separator(); + } + if ui.button("Exit Edit Points").clicked() { + let _ = app.run("shape.editPoints", json!({"on": false})); + ui.close(); + } + return; + } + // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. + if let Some(w) = app.squiggles.menu_word.clone() { + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); + // The gap is spaces; take it from the text so tabs or no-break spaces match. + let gap = app + .session + .active() + .and_then(|st| match w.tag { + crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), + crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), + _ => None, + }, + crate::squiggles::Tag::Notes { .. } => None, + }) + .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) + .unwrap_or(cut); + let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); + if let Some(o) = p.as_object_mut() { + match w.tag { + crate::squiggles::Tag::Shape { id, para } => { + o.insert("shape".into(), json!(id)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Cell { id, row, col, para } => { + o.insert("shape".into(), json!(id)); + o.insert("row".into(), json!(row)); + o.insert("col".into(), json!(col)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Notes { .. } => {} + } + } + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + let p = match w.tag { + crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), + crate::squiggles::Tag::Cell { id, row, col, para } => { + json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) + } + crate::squiggles::Tag::Notes { .. } => json!({}), + }; + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + app.squiggles.menu_word = None; + ui.close(); + } + if ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + app.squiggles.menu_word = None; + ui.close(); + } + ui.separator(); + } + let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); + for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + ui.separator(); + // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. + let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); + if editing { + if ui.button("Paragraph…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + ui.close(); + } + for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + ui.separator(); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } + } else if has_sel { + if ui.button("Edit Text").clicked() { + let _ = app.run("text.edit", json!({})); + ui.close(); + } + if crate::ribbon::enabled(app, "shape.editPoints") + && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) + && ui.button("Edit Points").clicked() + { + let _ = app.run("shape.editPoints", json!({"on": true})); + ui.close(); + } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } + ui.menu_button("Bring to Front", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + }); + ui.menu_button("Send to Back", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + }); + ui.menu_button("Group", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + }); + let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + let _ = app.run("shape.lock", json!({"locked": !locked})); + ui.close(); + } + let picture = app + .session + .active() + .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); + if picture && ui.button("Change Picture…").clicked() { + let _ = app.run("app.changePictureDialog", json!({})); + ui.close(); + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + if ui.button("Edit Alt Text…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("altText")); + ui.close(); + } + if ui.button("Set as Default Shape").clicked() { + let _ = app.run("shape.setDefault", json!({})); + ui.close(); + } + if ui.button("Size and Position…").clicked() { + let _ = app.run("shape.sizePosition", json!({})); + ui.close(); + } + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } + } else { + ui.menu_button("Layout", |ui| { + let layouts: Vec<(String, u32)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) + .unwrap_or_default(); + for (n, id) in layouts { + if ui.button(n).clicked() { + let _ = app.run("slide.layout", json!({"layout": id})); + ui.close(); + } + } + }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); + if ui.button("Reset Slide").clicked() { + let _ = app.run("slide.reset", json!({})); + ui.close(); + } + if ui.button("Format Background…").clicked() { + let _ = app.run("view.pane", json!({"pane": "background"})); + ui.close(); + } + if ui.button("New Comment").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("comment")); + ui.close(); + } + } + }); +} diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs new file mode 100644 index 000000000..be23d2ee3 --- /dev/null +++ b/crates/ui-egui/src/canvas/guides.rs @@ -0,0 +1,85 @@ +use super::*; + +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +pub(super) fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +pub(super) fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +pub(super) fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} diff --git a/crates/ui-egui/src/canvas/ime.rs b/crates/ui-egui/src/canvas/ime.rs new file mode 100644 index 000000000..ef752efa9 --- /dev/null +++ b/crates/ui-egui/src/canvas/ime.rs @@ -0,0 +1,43 @@ +use super::*; + +/// Where an input method composes for the slide (#101), on screen: at the insertion point while +/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is +/// nothing to type into. +pub fn ime_area(app: &SlideApp, xf: Xf) -> Option { + let st = app.session.active()?; + if let Some(ts) = &st.selection.text { + if ts.notes { + return None; + } + if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { + return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0))); + } + let sh = st.shape(ts.shape)?; + return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))); + } + if st.selection.shapes.len() != 1 { + return None; + } + let sh = st.shape(*st.selection.shapes.first()?)?; + if sh.is_line() { + return None; + } + Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))) +} + +/// Turn the input method on for the slide while it takes typing, with its candidate window at +/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types +/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own. +/// +/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed +/// text, owner and cause once at the end of the pass, after every IME event and editor command +/// has settled. +pub(super) fn ime_output(app: &mut SlideApp, ui: &Ui, xf: Xf) { + let ctx = ui.ctx(); + if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() { + return; + } + let Some(r) = ime_area(app, xf) else { return }; + let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default(); + app.ime_pending = Some(to_global * r); +} diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs new file mode 100644 index 000000000..4cfd62185 --- /dev/null +++ b/crates/ui-egui/src/canvas/input.rs @@ -0,0 +1,237 @@ +use super::*; + +/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; +/// other platforms leave the delta at zero. +/// +/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom +/// and two-finger scrolling are left as they are. +pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { + if ctx.will_discard() { + // Multi-pass layout runs this function again with the input already consumed. Acting on + // the discarded pass would start the twist and the replay would immediately end it, + // splitting one gesture into two undo steps. + return; + } + let delta = ctx.input(|i| i.rotation_delta()); + let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; + let turning = delta.abs() > 1e-4; + let blocked = app.show.is_some() + || app.backstage_open() + || app.dialog.as_ref().is_some_and(|d| d.modal()) + || app.palette.is_some() + || app.session.tool.dragging(); + let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); + let rotatable = app.session.active().is_some_and(|d| { + d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) + }); + if !blocked && turning && rotatable && (app.twisting || over) { + let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); + app.twisting = true; + } else if app.twisting { + let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); + app.twisting = false; + } +} + +/// A trackpad flick or mouse-wheel notch that should turn the page. +/// +/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative +/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — +/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. +pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { + const MIN_FLICK: f32 = 18.0; + let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; + (along.abs() > MIN_FLICK).then_some(along < 0.0) +} + +/// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, +/// including several notches delivered in one frame, and clamp at each end of the deck. +/// +/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so +/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction +/// drops what was left over. Returns the target, `None` when no wheel event was read. +pub(super) fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> Option { + let last = slides.checked_sub(1)?; + let mut target = current.min(last); + let mut handled = false; + for event in events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event + && matches!(unit, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page) + && !modifiers.ctrl + && !modifiers.command + && !modifiers.shift + && delta.y.is_finite() + && delta.y.abs() > 0.0 + && delta.y.abs() >= delta.x.abs() + { + if *carry * delta.y < 0.0 { + *carry = 0.0; + } + *carry += delta.y; + // The small slack keeps sums such as 10 × 0.1 from falling just short of a line. + let whole = (carry.abs() + 1e-4).floor(); + *carry -= whole.copysign(*carry); + let steps = whole.min(slides as f32) as usize; + target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; + handled = true; + } + } + handled.then_some(target) +} + +pub(super) fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { + events.iter().fold(egui::Vec2::ZERO, |sum, event| match event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } if !modifiers.ctrl && !modifiers.command => sum + *delta, + _ => sum, + }) +} + +pub(super) fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + if crate::eyedropper::canvas(app, ui, resp, xf) { + return; + } + if app.anim_preview_active() { + // A click on the slide ends the preview, then selects as usual. + if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { + app.stop_anim_preview(); + } else { + return; + } + } + let tol = (4.0 / xf.scale) as f64; + let m = mods(ui); + let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } + let p = xf.to_slide(pos); + let pressure = reported_pressure(ui); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; + let primary = ui.input(|i| i.pointer.primary_pressed()); + if resp.hovered() && !app.session.tool.dragging() { + let _ = app.session.pointer(ev(PointerKind::Move)); + } + let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary)); + let r = if secondary && !app.session.tool.dragging() { + // Select what's under the pointer before the context menu opens (on release). + app.session.pointer(ev(PointerKind::ContextClick)) + } else if resp.triple_clicked() { + app.session.pointer(ev(PointerKind::TripleClick)) + } else if resp.double_clicked() { + app.session.pointer(ev(PointerKind::DoubleClick)) + } else if primary && resp.hovered() && !resp.secondary_clicked() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) { + app.session.pointer(ev(PointerKind::Down)) + } else if resp.dragged_by(egui::PointerButton::Primary) || (app.session.tool.dragging() && ui.input(|i| i.pointer.primary_down())) { + app.session.pointer(ev(PointerKind::Drag)) + } else if app.session.tool.dragging() && ui.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { + app.session.pointer(ev(PointerKind::Up)) + } else { + Ok(serde_json::Value::Null) + }; + if let Err(e) = r { + app.set_status(e.to_string()); + } + // Cursor. The Format and Animation Painters override everything: their whole point is to + // tell the user the tool is armed and waiting for a click (#830). + let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some(); + let cursor = if painter_armed { + egui::CursorIcon::Copy + } else { + match (&app.session.tool.kind, &app.session.tool.hover) { + (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, + (ToolKind::TextBox, _) => egui::CursorIcon::Text, + (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, + (_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle), + (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::Shape { text: true, .. })) => { + if app.session.active().is_some_and(|d| d.selection.text.is_some()) { + egui::CursorIcon::Text + } else { + egui::CursorIcon::Move + } + } + (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, + } + }; + if resp.hovered() { + ui.ctx().set_cursor_icon(cursor); + } + if app.session.tool.dragging() { + ui.ctx().request_repaint(); + } +} + +/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. +fn reported_pressure(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), phase, .. } + if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => + { + Some(f.clamp(0.0, 1.0)) + } + _ => None, + }) + }) +} + +/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the +/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the +/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping +/// reduces to the classic NW/NE/E/SE/S/SW/W assignment. +pub(super) fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon { + // The resize handles are drawn from the shape's effective box (a placeholder inherits it + // from its layout), so the cursor follows the same one. + let Some(d) = session.active() else { return egui::CursorIcon::Default }; + let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default }; + let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh); + // The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward + // the handle. Corners resize along the diagonal, edge handles along their edge. The + // direction is normalised - the diagonal at 45 degrees whatever the shape's proportions - + // so a wide rectangle still shows a diagonal cursor on its corners. + let (dx, dy): (f64, f64) = match handle { + 0 => (-1.0, -1.0), + 1 => (0.0, -1.0), + 2 => (1.0, -1.0), + 3 => (1.0, 0.0), + 4 => (1.0, 1.0), + 5 => (0.0, 1.0), + 6 => (-1.0, 1.0), + _ => (-1.0, 0.0), + }; + // Map through the linear part of the transform (rotation, flips): two points along the + // axis, the difference is the slide-space direction. + let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0); + let a = x.affine(); + let p0 = a * centre; + let p1 = a * Point::new(centre.x + dx, centre.y + dy); + let v = p1 - p0; + if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) { + return egui::CursorIcon::Default; + } + // Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down + // (vertical), 135° = down-left (NE-SW). Pick the closest of the four. + let mut deg = v.y.atan2(v.x).to_degrees(); + if deg < 0.0 { + deg += 180.0; + } + if (22.5..67.5).contains(°) { + egui::CursorIcon::ResizeNwSe + } else if (67.5..112.5).contains(°) { + egui::CursorIcon::ResizeVertical + } else if (112.5..157.5).contains(°) { + egui::CursorIcon::ResizeNeSw + } else { + egui::CursorIcon::ResizeHorizontal + } +} diff --git a/crates/ui-egui/src/canvas/media.rs b/crates/ui-egui/src/canvas/media.rs new file mode 100644 index 000000000..a859beb82 --- /dev/null +++ b/crates/ui-egui/src/canvas/media.rs @@ -0,0 +1,75 @@ +use super::*; + +/// Play/pause on an animated GIF under the pointer or selected on its own. +pub(super) fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) { + let Some(d) = app.session.active() else { return }; + if d.selection.text.is_some() || app.session.tool.dragging() { + return; + } + let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None }; + let pointer = ui.input(|i| i.pointer.hover_pos()); + let mut toggle = None; + for g in gifs { + let r = screen_rect(xf, &g.rect); + if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) { + continue; + } + let b = crate::media::gif_button_rect(r); + // Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF). + let resp = ui.interact(b, ui.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" })); + crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered()); + if resp.clicked() { + toggle = Some(g.id); + } + } + if let Some(id) = toggle { + let _ = app.run("media.gifPlay", json!({"id": id})); + } +} + +/// Video playing in the editor: its current frame over the poster. +pub(super) fn media_frames(app: &SlideApp, painter: &egui::Painter, xf: Xf) { + let Some(d) = app.session.active() else { return }; + if d.selection.target != Target::Slides || !app.media.any_active() { + return; + } + for (id, x, clip) in crate::media::slide_media(&d.doc, d.selection.slide) { + if clip.video { + crate::media::paint_frame(painter, &app.media, id, screen_rect(xf, &x), clip.crop); + } + } +} + +/// The play/pause and timeline bar under a selected audio or video (PowerPoint shows it on +/// selection or hover). +pub(super) fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { + let Some(d) = app.session.active() else { return }; + let uid = d.uid; + if d.selection.target != Target::Slides || d.selection.text.is_some() || app.session.tool.dragging() { + return; + } + let pointer = ui.input(|i| i.pointer.hover_pos()); + let media = crate::media::slide_media(&d.doc, d.selection.slide); + let pick = media.iter().find(|(id, _, _)| d.selection.shapes.len() == 1 && d.selection.shapes[0] == *id).or_else(|| { + media.iter().find(|(id, x, _)| { + let r = screen_rect(xf, x); + app.media.status(*id).is_some() || pointer.is_some_and(|p| r.expand2(vec2(0.0, 40.0)).contains(p)) + }) + }); + let Some((id, x, clip)) = pick.cloned() else { return }; + let doc = d.doc.clone(); + let r = screen_rect(xf, &x); + let w = r.width().max(230.0); + let bar = Rect::from_min_size(pos2(r.center().x - w / 2.0, r.max.y + 6.0), vec2(w, 26.0)); + let status = app.media.status(id); + match crate::media::control_bar(ui, bar, status.as_ref(), &clip) { + Some(crate::media::BarAction::Toggle) => app.media.toggle(uid, &doc, id, &clip, crate::media::Owner::Editor), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(uid, &doc, id, &clip, t, crate::media::Owner::Editor), + Some(crate::media::BarAction::Nudge(dt)) => { + let pos = status.map(|s| s.position).unwrap_or(0.0); + app.media.seek(uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); + } + None => {} + } +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs new file mode 100644 index 000000000..94c731b8f --- /dev/null +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -0,0 +1,283 @@ +use super::*; + +fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { + for i in 0..4 { + p.line_segment([pts[i], pts[(i + 1) % 4]], stroke); + } +} + +pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + // Spelling squiggles under misspelt words (checked in the background). + if let Some(st) = app.session.active() { + let lines = app.squiggles.update(st, Some(ui.ctx())); + let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); + for (a, b) in lines { + crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); + } + } + let Some(st) = app.session.active() else { return }; + let sel_stroke = Stroke::new(1.0, t.selection); + let editing = st.selection.text.clone(); + for id in &st.selection.shapes { + let Some(sh) = st.shape(*id) else { continue }; + let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); + let a = x.affine(); + let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q)); + if sh.is_line() { + let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))); + let glued = match sh.kind { + deckcraft_model::ShapeKind::Connector { start, end } => [start.is_some(), end.is_some()], + _ => [false, false], + }; + for (q, g) in [p0, p1].into_iter().zip(glued) { + // Glued ends are filled with the accent, like attached connector ends. + let fill = if g { t.accent } else { t.handle_fill }; + painter.circle(q, 4.5, fill, Stroke::new(1.0, if g { t.handle_fill } else { t.handle_stroke })); + } + continue; + } + let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes); + if app.session.tool.editing_points(st) == Some(sh.id) { + // Edit Points: the outline's vertices are the handles. + // No selection box: the outline and its vertices are what's edited. + if let Some((path, index)) = app.session.tool.edit_vertex + && let Some((hin, hout, v)) = deckcraft_engine::cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for h in [hin, hout].into_iter().flatten() { + painter.line_segment([xf.to_screen(v), xf.to_screen(h)], sel_stroke); + painter.rect( + egui::Rect::from_center_size(xf.to_screen(h), egui::vec2(6.0, 6.0)), + 0.0, + Color32::WHITE, + Stroke::new(1.0, t.selection), + egui::StrokeKind::Middle, + ); + } + } + for q in deckcraft_engine::cmd::points::slide_vertices(&st.doc, &st.selection, sh).into_iter().flatten() { + let c = xf.to_screen(q); + painter.rect( + egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)), + 0.0, + Color32::BLACK, + Stroke::new(1.0, Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + continue; + } + if editing_this { + // Dashed box while editing text. + for i in 0..4 { + let (s, e) = (corners[i], corners[(i + 1) % 4]); + painter.add(egui::Shape::dashed_line(&[s, e], Stroke::new(1.0, t.selection), 4.0, 3.0)); + } + } else { + poly(painter, corners, sel_stroke); + } + if sh.locked && !editing_this { + // Locked: greyed handles that don't respond, no rotate or adjust handles. + for h in box_handles(&x).0 { + painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill)); + } + continue; + } + if st.selection.shapes.len() == 1 || !editing_this { + let (hs, rot) = box_handles(&x); + let rot_s = xf.to_screen(rot); + if !editing_this && sh.ph.as_ref().is_none_or(|_| true) { + let top_mid = xf.to_screen(hs[1]); + painter.line_segment([top_mid, rot_s], sel_stroke); + rotate_handle(painter, rot_s, t); + } + for h in hs { + painter.circle(xf.to_screen(h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); + } + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + for hd in &geo.handles { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } + } + } + // Caret / text selection, in a shape or a table cell. + if let Some(ts) = &editing { + for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { + painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE)); + } + // An input method's composition: underlined in place, thicker under the part being converted. + for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { + painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret)); + } + if !ts.is_range() { + let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); + // An empty placeholder shows the caret where typing begins too. + if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { + // In the colour of the text being typed: a black caret is lost on a dark slide. + let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32); + painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color)); + } + } + } + // Table cell editing: outline the cell (merged cells as drawn). + if let Some(ts) = &editing + && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + && let Some(cr) = f.cell_rect(r, c) + { + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] + .map(|q| xf.to_screen(f.affine * q)); + poly(painter, pts, Stroke::new(2.0, t.accent)); + } + // Shade the selected table cells in the text-selection color, each merged cell as it is drawn. + if let Some((id, ..)) = st.selection.cells + && st.selection.text.is_none() + && let Some(sh) = st.shape(id) + && let deckcraft_model::ShapeKind::Table(table) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + { + use deckcraft_engine::cmd::table::{in_range, selected_range}; + let range = selected_range(st.selection.cells, id, table); + for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) { + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)]; + painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); + } + } + // Smart guides, marquee, previews. + for g in &app.session.tool.guides { + let (a, b) = + if g.vertical { (Point::new(g.pos, g.from), Point::new(g.pos, g.to)) } else { (Point::new(g.from, g.pos), Point::new(g.to, g.pos)) }; + painter.add(egui::Shape::dashed_line(&[xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, t.guide), 5.0, 3.0)); + } + if let Some(m) = app.session.tool.marquee { + let r = Rect::from_two_pos(xf.to_screen(Point::new(m.x0, m.y0)), xf.to_screen(Point::new(m.x1, m.y1))); + painter.rect( + r, + CornerRadius::ZERO, + Color32::from_rgba_unmultiplied(80, 130, 230, 30), + Stroke::new(1.0, Color32::from_rgb(80, 130, 230)), + egui::StrokeKind::Inside, + ); + } + if let Some(pv) = app.session.tool.preview { + draw_preview(app, painter, xf, pv, t); + } + // Freeform being drawn. + let path = &app.session.tool.path_preview; + if path.len() > 1 { + let pts: Vec = if app.session.tool.path_smooth && path.len() > 2 { + let d = deckcraft_engine::cmd::shape::freeform_path(path, false, true); + let mut v = vec![]; + let bp = deckcraft_render::parse_path(&d); + deckcraft_geom::preset_flatten(&bp, &mut |el| match el { + deckcraft_geom::PathEl::MoveTo(q) | deckcraft_geom::PathEl::LineTo(q) => v.push(q), + _ => {} + }); + v + } else { + path.clone() + }; + painter.add(egui::Shape::line(pts.iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.5, t.accent))); + if app.session.tool.path_closing + && let Some(s0) = path.first() + { + painter.circle(xf.to_screen(*s0), 5.0, t.accent, Stroke::new(1.5, t.handle_fill)); + } + } + // Connection sites of the shape under a connector end, and the site it would glue to. + for q in &app.session.tool.sites { + let c = xf.to_screen(*q); + painter.circle(c, 3.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); + } + if let Some(g) = app.session.tool.glue { + painter.circle(xf.to_screen(g), 5.0, t.accent, Stroke::new(1.5, t.handle_fill)); + } + if app.session.tool.ink_preview.len() > 1 + && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind + { + let pts = &app.session.tool.ink_preview; + let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); + let c = theme::to_color32(*color); + let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; + if legacy || mode == "highlighter" { + // One line, so see-through highlighter ink doesn't darken where segments meet. + let line: Vec = pts.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + painter.add(egui::Shape::line(line, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + } else { + for pair in pts.windows(2) { + let [q0, q1] = pair else { continue }; + let a = deckcraft_engine::model::ink_sample_width(*width, q0.2, legacy); + let b = deckcraft_engine::model::ink_sample_width(*width, q1.2, legacy); + let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; + let p0 = xf.to_screen(Point::new(q0.0, q0.1)); + let p1 = xf.to_screen(Point::new(q1.0, q1.1)); + painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); + } + } + } +} + +/// The text selection (quads) of the text being edited, in slide space: in a shape or a table +/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. +pub(crate) struct TextMarks { + pub selection: Vec<[Point; 4]>, +} + +pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { + let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; + let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; + let (s0, s1) = ts.ordered(); + let selection = l + .selection_rects(pos(s0), pos(s1)) + .into_iter() + .map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q)) + .collect(); + Some(TextMarks { selection }) +} + +fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { + p.circle_filled(c, 6.0, t.handle_fill); + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: (0..=20) + .map(|i| { + let a = -0.4 + i as f32 / 20.0 * 5.2; + pos2(c.x + 4.0 * a.cos(), c.y + 4.0 * a.sin()) + }) + .collect(), + closed: false, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.3, t.handle_stroke).into(), + })); +} + +fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) { + let preset = match &app.session.tool.kind { + ToolKind::Shape { preset } => preset.clone(), + _ => "rect".into(), + }; + if preset == deckcraft_engine::tools::DRAW_TABLE { + // A border inside a table (flat), or the outline of a new table. + let (a, b) = (xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); + if x.w < 0.5 || x.h < 0.5 { + p.line_segment([a, b], Stroke::new(1.5, t.accent)); + } else { + p.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + return; + } + if deckcraft_geom::preset::is_line_like(&preset) { + let a = x.affine(); + p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent)); + return; + } + let r = Rect::from_two_pos(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); + if r.width() < 1.0 || r.height() < 1.0 { + return; + } + crate::ribbon::paint_preset(p, r, &preset, t.accent.gamma_multiply(0.25), t.accent); +} diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs new file mode 100644 index 000000000..bb8cb71af --- /dev/null +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -0,0 +1,235 @@ +use super::*; + +/// The ruler's numbered step in points and the ticks per step: quarter inches, or half +/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a +/// tick per point or pixel would be a solid bar. +pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) { + match unit { + deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2), + _ => (72.0, 4), + } +} + +pub(super) fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, t: &Tokens) { + let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0)); + let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y)); + p.rect_filled(top, CornerRadius::ZERO, t.card); + p.rect_filled(left, CornerRadius::ZERO, t.card); + let (step, sub) = ruler_steps(unit); + let tick = step as f32 * scale / sub as f32; + let len = |k: u32| { + if k.is_multiple_of(sub) { + 8.0 + } else if k.is_multiple_of(2) { + 5.0 + } else { + 3.0 + } + }; + let mut k = 0; + loop { + let x = slide.min.x + k as f32 * tick; + if x > slide.max.x { + break; + } + let h = len(k); + p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); + if k.is_multiple_of(sub) { + p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim); + } + k += 1; + } + let mut k = 0; + loop { + let y = slide.min.y + k as f32 * tick; + if y > slide.max.y { + break; + } + let w = len(k); + p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint)); + k += 1; + } +} + +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +pub(super) fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler")); + let id = ui.scope_id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); + } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.scope_id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } +} diff --git a/crates/ui-egui/src/canvas/scroll.rs b/crates/ui-egui/src/canvas/scroll.rs new file mode 100644 index 000000000..ff3b072ca --- /dev/null +++ b/crates/ui-egui/src/canvas/scroll.rs @@ -0,0 +1,46 @@ +use super::*; + +/// Thickness of the canvas scroll bars, in points. +const SCROLL_BAR: f32 = 10.0; + +/// The canvas scroll bar along `axis` (0 across, 1 down): its track, its thumb for `offset`, and +/// how far the thumb can travel. `None` when the slide fits that way. `inset` keeps the rulers' +/// strips clear. +pub(super) fn scroll_bar(avail: Rect, inset: f32, overflow: egui::Vec2, offset: egui::Vec2, axis: usize) -> Option<(Rect, Rect, f32)> { + let over = overflow[axis]; + if !over.is_finite() || over <= 0.0 { + return None; + } + // Both bars showing leave the corner between them empty. + let corner = if overflow[1 - axis] > 0.0 { SCROLL_BAR } else { 0.0 }; + let track = if axis == 0 { + Rect::from_min_max(pos2(avail.min.x + inset, avail.max.y - SCROLL_BAR), pos2(avail.max.x - corner, avail.max.y)) + } else { + Rect::from_min_max(pos2(avail.max.x - SCROLL_BAR, avail.min.y + inset), pos2(avail.max.x, avail.max.y - corner)) + }; + let len = track.size()[axis].max(0.0); + let view = avail.size()[axis]; + let thumb_len = (len * view / (view + over)).max(SCROLL_BAR * 2.0).min(len); + let travel = (len - thumb_len).max(0.0); + let at = travel * (offset[axis] / over).clamp(0.0, 1.0); + let mut thumb = track; + thumb.min[axis] = track.min[axis] + at; + thumb.max[axis] = thumb.min[axis] + thumb_len; + Some((track, thumb, travel)) +} + +/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's +/// top-left corner. +pub(super) 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, + } +} From c8a6363a0f22b93657653d5978dc906d5a9cbcf2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:31:37 +0000 Subject: [PATCH 41/90] refactor(ui-egui): split dialogs.rs into one file per dialog under dialogs/ --- crates/ui-egui/src/dialogs.rs | 3370 +------------------ crates/ui-egui/src/dialogs/about.rs | 66 + crates/ui-egui/src/dialogs/alt_text.rs | 11 + crates/ui-egui/src/dialogs/body.rs | 1345 ++++++++ crates/ui-egui/src/dialogs/columns.rs | 56 + crates/ui-egui/src/dialogs/date_time.rs | 83 + crates/ui-egui/src/dialogs/equation.rs | 41 + crates/ui-egui/src/dialogs/export.rs | 60 + crates/ui-egui/src/dialogs/find.rs | 91 + crates/ui-egui/src/dialogs/font.rs | 162 + crates/ui-egui/src/dialogs/grid_options.rs | 38 + crates/ui-egui/src/dialogs/hyperlink.rs | 181 + crates/ui-egui/src/dialogs/language.rs | 28 + crates/ui-egui/src/dialogs/paragraph.rs | 197 ++ crates/ui-egui/src/dialogs/photo_album.rs | 339 ++ crates/ui-egui/src/dialogs/preferences.rs | 26 + crates/ui-egui/src/dialogs/size_position.rs | 207 ++ crates/ui-egui/src/dialogs/slide_size.rs | 29 + crates/ui-egui/src/dialogs/tabs.rs | 91 + crates/ui-egui/src/dialogs/theme_colors.rs | 95 + crates/ui-egui/src/dialogs/theme_fonts.rs | 114 + crates/ui-egui/src/dialogs/trim.rs | 50 + crates/ui-egui/src/dialogs/zoom.rs | 62 + 23 files changed, 3409 insertions(+), 3333 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/about.rs create mode 100644 crates/ui-egui/src/dialogs/alt_text.rs create mode 100644 crates/ui-egui/src/dialogs/body.rs create mode 100644 crates/ui-egui/src/dialogs/columns.rs create mode 100644 crates/ui-egui/src/dialogs/date_time.rs create mode 100644 crates/ui-egui/src/dialogs/equation.rs create mode 100644 crates/ui-egui/src/dialogs/export.rs create mode 100644 crates/ui-egui/src/dialogs/find.rs create mode 100644 crates/ui-egui/src/dialogs/font.rs create mode 100644 crates/ui-egui/src/dialogs/grid_options.rs create mode 100644 crates/ui-egui/src/dialogs/hyperlink.rs create mode 100644 crates/ui-egui/src/dialogs/language.rs create mode 100644 crates/ui-egui/src/dialogs/paragraph.rs create mode 100644 crates/ui-egui/src/dialogs/photo_album.rs create mode 100644 crates/ui-egui/src/dialogs/preferences.rs create mode 100644 crates/ui-egui/src/dialogs/size_position.rs create mode 100644 crates/ui-egui/src/dialogs/slide_size.rs create mode 100644 crates/ui-egui/src/dialogs/tabs.rs create mode 100644 crates/ui-egui/src/dialogs/theme_colors.rs create mode 100644 crates/ui-egui/src/dialogs/theme_fonts.rs create mode 100644 crates/ui-egui/src/dialogs/trim.rs create mode 100644 crates/ui-egui/src/dialogs/zoom.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1f50e7f2c..7f25f2d4b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -126,21 +126,6 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { } } -/// Room the About credits need for their table; the About tab keeps its compact size. -const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0); - -/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the -/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first -/// credits row together, so the text was garbled and the tabs stopped answering (#843). -fn credits_block(ui: &mut Ui, models: bool) -> Rect { - ui.allocate_ui(CREDITS_SIZE, |ui| { - ui.set_min_size(CREDITS_SIZE); - if models { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) } - }) - .response - .rect -} - fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { let mut ok = false; let mut cancel = false; @@ -159,31 +144,6 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { (ok, cancel) } -/// What the Preferences dialog can change, as it was when the dialog opened. -fn preferences_snapshot(app: &SlideApp) -> Value { - json!({ - "dark": app.ui.brightness == theme::Brightness::Dark, - "uiScale": app.ui.ui_scale, - "prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null), - }) -} - -/// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button). -fn restore_preferences(app: &mut SlideApp, d: &Dialog) { - let Some(before) = d.params.get("before") else { return }; - if let Some(dark) = before.get("dark").and_then(Value::as_bool) - && dark != (app.ui.brightness == theme::Brightness::Dark) - { - let _ = app.run("view.dark", json!({ "on": dark })); - } - if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) { - app.ui.ui_scale = scale as f32; - } - if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) { - app.session.prefs = prefs; - } -} - /// Draw a labelled single-line field bound to `key`, falling back to `default`, and store the /// edited value in the dialog. fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { @@ -195,18 +155,6 @@ fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { d.fields.insert(key.into(), v); } -/// "Mark as decorative" starts checked when every selected shape is already decorative. -fn alt_text_starts_decorative(decorative: impl IntoIterator) -> bool { - let mut any = false; - for on in decorative { - if !on { - return false; - } - any = true; - } - any -} - fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> bool { let mut v = d.get(key, if default { "1" } else { "0" }) == "1"; ui.checkbox(&mut v, label); @@ -214,2198 +162,10 @@ fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> v } -/// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside. -fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) { - d.fields.insert(key.into(), format!("{:.2}", pt / ppu)); - d.fields.insert(format!("{key}Pt"), pt.to_string()); -} - -/// A Slide Size field in points: the exact value while the text is still its rendering, else -/// the number typed (`default` when it is not one). -fn slide_dim_points(d: &Dialog, key: &str, ppu: f64, default: f64) -> f64 { - let text = field_of(d, key); - if let Some(pt) = exact_of(d, key) - && text == format!("{:.2}", pt / ppu) - { - return pt; - } - text.trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).map_or(default, |v| v * ppu) -} - -/// The unit's abbreviation in a field label, "Width (cm):". -fn unit_abbr(unit: deckcraft_geom::Unit) -> &'static str { - match unit { - deckcraft_geom::Unit::Inches => "in", - deckcraft_geom::Unit::Centimeters => "cm", - deckcraft_geom::Unit::Points => "pt", - deckcraft_geom::Unit::Pixels => "px", - } -} - fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) } -fn album_items(d: &Dialog) -> Option> { - let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]"); - serde_json::from_str::(raw).ok().and_then(|v| v.as_array().cloned()) -} - -fn store_album_items(d: &mut Dialog, items: &[Value]) { - let raw = serde_json::to_string(items).unwrap_or_else(|_| "[]".into()); - d.fields.insert("items".into(), raw); -} - -fn album_item_label(v: &Value) -> String { - if let Some(text) = v.get("text").and_then(Value::as_str) { - let text = text.trim(); - if text.is_empty() { "Text Box".to_string() } else { format!("Text: {text}") } - } else { - v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("Picture").to_string() - } -} - -fn pick_album_picture(app: &mut SlideApp) -> Option { - let path = if let Some(pick) = app.services.pick_open.as_mut() { - pick("picture") - } else { - app.set_status("Pass pictures to insert.photoAlbum"); - None - }; - let path = path?; - let loaded = if let Some(read) = app.services.read.as_ref() { - match read(&path) { - Ok(bytes) => bytes, - Err(e) => { - app.set_status(e); - return None; - } - } - } else { - #[cfg(not(target_arch = "wasm32"))] - { - match std::fs::read(&path) { - Ok(bytes) => bytes, - Err(e) => { - app.set_status(format!("{path}: {e}")); - return None; - } - } - } - #[cfg(target_arch = "wasm32")] - { - app.set_status("The web build inserts album pictures from data passed to insert.photoAlbum"); - return None; - } - }; - if loaded.is_empty() { - app.set_status("That file is empty"); - return None; - } - if loaded.len() > 32 * 1024 * 1024 { - app.set_status("That picture is larger than 32 MB"); - return None; - } - let name = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).filter(|s| !s.is_empty()).unwrap_or("picture").to_string(); - Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)})) -} - -/// Draw an accessible album combo box, preserving its current/default selection and storing a -/// newly chosen key; the caller supplies the visible label. -fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &str, key: &str, default: &str, choices: &[(&str, &str)]) { - let cur = d.get(key, default); - let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); - let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { - for (k, l) in choices { - if ui.selectable_label(cur == *k, *l).clicked() { - d.fields.insert(key.into(), (*k).to_string()); - } - } - }); - let access = name.to_string(); - let shown = label.to_string(); - opened.response.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone()); - info.current_text_value = Some(shown.clone()); - info - }); -} - -const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]; - -fn current_theme(app: &SlideApp) -> String { - app.session - .active() - .and_then(|d| d.doc.masters.first()) - .map(|m| m.theme.name.clone()) - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| "DeckCraft".into()) -} - -fn album_json_id(v: &Value) -> Option { - v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) -} - -/// Copy a command's dialog params into fields once. A fresh album has no `items`. -fn seed_photo_album(app: &SlideApp, d: &mut Dialog) { - if d.fields.contains_key("seeded") { - return; - } - d.fields.insert("seeded".into(), "1".into()); - let p = d.params.clone(); - if let Some(items) = p.get("items").and_then(Value::as_array) { - store_album_items(d, items); - d.fields.insert("editing".into(), "1".into()); - } - for key in ["layout", "frame", "theme"] { - if let Some(v) = p.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) { - d.fields.insert(key.into(), v.to_string()); - } - } - for key in ["captions", "grayscale"] { - if let Some(v) = p.get(key).and_then(Value::as_bool) { - d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); - } - } - if let Some(id) = p.get("album").and_then(album_json_id).filter(|n| *n != 0) { - d.fields.insert("album".into(), id.to_string()); - } - let theme_was = d.fields.get("theme").cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| current_theme(app)); - if !d.fields.contains_key("theme") { - d.fields.insert("theme".into(), theme_was.clone()); - } - d.fields.entry("themeWas".into()).or_insert(theme_was); -} - -fn selected_album_picture(d: &Dialog, items: &[Value]) -> bool { - let i = d.fields.get("sel").and_then(|s| s.parse::().ok()).unwrap_or(0); - items.get(i).is_some_and(|item| item.get("data").is_some() || item.get("path").is_some()) -} - -fn with_selected_picture(d: &mut Dialog, f: impl FnOnce(&mut serde_json::Map)) { - let Some(mut list) = album_items(d) else { return }; - let i = d.fields.get("sel").and_then(|s| s.parse::().ok()).unwrap_or(0); - let Some(item) = list.get_mut(i) else { return }; - if item.get("data").is_none() && item.get("path").is_none() { - return; - } - let Some(obj) = item.as_object_mut() else { return }; - f(obj); - store_album_items(d, &list); -} - -/// Photo Album. Returns true when the dialog should close. -fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - seed_photo_album(app, d); - ui.set_min_width(440.0); - egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| { - ui.set_min_width(420.0); - ui.label("Pictures in album"); - let items = album_items(d).unwrap_or_default(); - let mut sel = d.get("sel", "0").parse::().unwrap_or(0); - // Fixed height so adding pictures does not push Create past the window. - egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| { - if items.is_empty() { - ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim)); - } - for (i, item) in items.iter().enumerate() { - if ui.selectable_label(sel == i, album_item_label(item)).clicked() { - sel = i; - } - } - }); - d.fields.insert("sel".into(), sel.to_string()); - ui.horizontal(|ui| { - if ui.button("Add Pictures…").clicked() - && let Some(item) = pick_album_picture(app) - { - match album_items(d) { - Some(mut list) if list.len() < 48 => { - list.push(item); - let next = list.len().saturating_sub(1); - store_album_items(d, &list); - d.fields.insert("sel".into(), next.to_string()); - } - Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"), - None => app.set_status("The picture list is invalid"), - } - } - if ui.button("New Text Box").clicked() { - let text = d.get("boxText", "Text Box"); - let text = text.trim(); - if text.is_empty() { - app.set_status("Type the text for the new text box"); - } else if text.chars().count() > 400 { - app.set_status("Text is longer than 400 characters"); - } else if let Some(mut list) = album_items(d) { - if list.len() >= 48 { - app.set_status("A photo album holds at most 48 pictures or text boxes"); - } else { - list.push(json!({"text": text})); - let next = list.len().saturating_sub(1); - store_album_items(d, &list); - d.fields.insert("sel".into(), next.to_string()); - } - } - } - if ui.button("Remove").clicked() - && let Some(mut list) = album_items(d) - && let Some(i) = d.get("sel", "0").parse::().ok() - && i < list.len() - { - list.remove(i); - store_album_items(d, &list); - let next = i.min(list.len().saturating_sub(1)); - d.fields.insert("sel".into(), next.to_string()); - } - }); - field(ui, d, "Text box:", "boxText", "Text Box"); - let picture = selected_album_picture(d, &items); - ui.label(album_picture_status(&items, sel)); - ui.horizontal_wrapped(|ui| { - ui.add_enabled_ui(picture, |ui| { - if ui.button("Rotate Left").clicked() { - with_selected_picture(d, |obj| { - let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); - let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0; - obj.insert("rot".into(), json!(next)); - }); - } - if ui.button("Rotate Right").clicked() { - with_selected_picture(d, |obj| { - let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); - let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0; - obj.insert("rot".into(), json!(next)); - }); - } - if ui.button("More Brightness").clicked() { - nudge_picture(d, "brightness", 0.1); - } - if ui.button("Less Brightness").clicked() { - nudge_picture(d, "brightness", -0.1); - } - if ui.button("More Contrast").clicked() { - nudge_picture(d, "contrast", 0.1); - } - if ui.button("Less Contrast").clicked() { - nudge_picture(d, "contrast", -0.1); - } - }); - }); - ui.add_space(4.0); - ui.label("Picture layout"); - album_choice( - ui, - d, - "album_layout", - "Picture layout", - "layout", - "1", - &[ - ("fit", "Fit to slide"), - ("1", "1 picture"), - ("2", "2 pictures"), - ("4", "4 pictures"), - ("1title", "1 picture with title"), - ("2title", "2 pictures with title"), - ("4title", "4 pictures with title"), - ], - ); - ui.label("Frame shape"); - album_choice( - ui, - d, - "album_frame", - "Frame shape", - "frame", - "rect", - &[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")], - ); - check(ui, d, "Captions under pictures", "captions", false); - check(ui, d, "ALL pictures black and white", "grayscale", false); - ui.label("Album theme"); - album_choice(ui, d, "album_theme", "Album theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); - }); - let captions = d.fields.get("captions").is_some_and(|v| v == "1"); - let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1"); - let editing = d.fields.get("editing").is_some_and(|v| v == "1"); - let (ok, cancel) = buttons(ui, if editing { "Update" } else { "Create" }); - if ok { - let Some(list) = album_items(d) else { - app.set_status("The picture list is invalid"); - return false; - }; - if list.is_empty() { - app.set_status("Add a picture or a text box first"); - return false; - } - let layout = d.get("layout", "1"); - let frame = d.get("frame", "rect"); - let theme = d.get("theme", "DeckCraft"); - let theme_was = d.fields.get("themeWas").cloned().unwrap_or_default(); - let mut payload = json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions, "grayscale": grayscale}); - if theme != theme_was - && let Some(obj) = payload.as_object_mut() - { - obj.insert("theme".into(), json!(theme)); - } - if editing { - if let Some(album) = d.fields.get("album").filter(|s| !s.is_empty() && s.as_str() != "0") - && let Ok(id) = album.parse::() - && let Some(obj) = payload.as_object_mut() - { - obj.insert("album".into(), json!(id)); - } - return app.run("insert.photoAlbumEdit", payload).is_ok(); - } - return app.run("insert.photoAlbum", payload).is_ok(); - } - cancel -} - -fn album_picture_status(items: &[Value], sel: usize) -> String { - let Some(item) = items.get(sel) else { - return "Select a picture to rotate it or change brightness and contrast".into(); - }; - if item.get("data").is_none() && item.get("path").is_none() { - return "Text boxes have no picture options".into(); - } - let rot = item.get("rot").and_then(Value::as_f64).unwrap_or(0.0); - let brightness = item.get("brightness").and_then(Value::as_f64).unwrap_or(0.0); - let contrast = item.get("contrast").and_then(Value::as_f64).unwrap_or(0.0); - format!("Rotation {rot:.0}°, brightness {brightness:.1}, contrast {contrast:.1}") -} - -fn nudge_picture(d: &mut Dialog, key: &str, delta: f64) { - with_selected_picture(d, |obj| { - let cur = obj.get(key).and_then(Value::as_f64).unwrap_or(0.0); - let next = (((cur + delta) * 10.0).round() / 10.0).clamp(-1.0, 1.0); - obj.insert(key.into(), json!(next)); - }); -} - -fn seed_theme_colors(d: &mut Dialog) { - if d.fields.contains_key("seeded") { - return; - } - let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); - d.fields.insert("schemeName".into(), name.to_string()); - let obj = d.params.get("colors").and_then(Value::as_object); - for slot in SchemeSlot::THEME { - let hex = obj.and_then(|o| o.get(slot.xml_name())).and_then(Value::as_str).unwrap_or("000000"); - d.fields.insert(slot.xml_name().into(), hex.to_string()); - } - d.fields.insert("seeded".into(), "1".into()); -} - -/// Draw one theme-colour row with its hex editor and store the value for the scheme slot. -fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { - let key = slot.xml_name(); - let mut v = d.get(key, "000000"); - ui.horizontal(|ui| { - ui.add_sized(vec2(188.0, 18.0), egui::Label::new(slot.label())); - let fill = deckcraft_color::Rgba::from_hex(&v).map(theme::to_color32).unwrap_or_else(|| Color32::from_gray(200)); - let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); - ui.painter().rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); - let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key))); - let shown = v.clone(); - resp.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, format!("{} hex", slot.label())); - info.current_text_value = Some(shown.clone()); - info - }); - }); - d.fields.insert(key.into(), v); -} - -fn theme_color_payload(d: &mut Dialog) -> Result { - let name = d.get("schemeName", "").trim().to_string(); - if name.is_empty() { - return Err("Enter a scheme name.".into()); - } - let mut colors = serde_json::Map::new(); - for slot in SchemeSlot::THEME { - let raw = d.get(slot.xml_name(), ""); - let Some(rgb) = deckcraft_color::Rgba::from_hex(raw.trim()) else { - return Err(format!("{} needs a colour like #RRGGBB.", slot.label())); - }; - colors.insert(slot.xml_name().into(), Value::String(format!("#{}", rgb.hex()))); - } - Ok(json!({"name": name, "colors": colors})) -} - -/// Create New Theme Colors. Returns true when the dialog should close. -fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - seed_theme_colors(d); - // The window keeps the height of its contents and will not grow after the first frame, so the - // rows stay short enough that Save is inside that rect. - ui.style_mut().spacing.interact_size.y = 16.0; - ui.style_mut().spacing.item_spacing.y = 2.0; - ui.style_mut().spacing.button_padding.y = 2.0; - let left = [SchemeSlot::Dk1, SchemeSlot::Lt1, SchemeSlot::Dk2, SchemeSlot::Lt2, SchemeSlot::Hlink, SchemeSlot::FolHlink]; - let right = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; - egui::Grid::new("theme-colors").spacing(vec2(18.0, 4.0)).show(ui, |ui| { - for (a, b) in left.into_iter().zip(right) { - theme_color_row(ui, d, a); - theme_color_row(ui, d, b); - ui.end_row(); - } - }); - ui.add_space(6.0); - let mut name = d.get("schemeName", ""); - ui.horizontal(|ui| { - ui.label("Name:"); - let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); - let shown = name.clone(); - resp.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name"); - info.current_text_value = Some(shown.clone()); - info - }); - }); - d.fields.insert("schemeName".into(), name); - ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); - let (ok, cancel) = buttons(ui, "Save"); - if ok { - match theme_color_payload(d) { - Ok(payload) => return app.run("design.colors", payload).is_ok(), - Err(msg) => { - app.set_status(msg); - return false; - } - } - } - cancel -} - -fn seed_theme_fonts(d: &mut Dialog) { - if d.fields.contains_key("seeded") { - return; - } - let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); - let major = d.params.get("major").and_then(Value::as_str).unwrap_or("Inter"); - let minor = d.params.get("minor").and_then(Value::as_str).unwrap_or(major); - d.fields.insert("schemeName".into(), name.to_string()); - d.fields.insert("major".into(), major.to_string()); - d.fields.insert("minor".into(), minor.to_string()); - d.fields.insert("seeded".into(), "1".into()); -} - -fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec { - let mut v = crate::ribbon::font_families(app); - for scheme in deckcraft_model::theme::builtin_font_schemes() { - v.push(scheme.major.latin); - v.push(scheme.minor.latin); - } - if !selected.is_empty() { - v.push(selected.to_string()); - } - let mut seen = std::collections::HashSet::new(); - v.retain(|face| !face.is_empty() && seen.insert(face.clone())); - v -} - -/// Draw an accessible font combo box and update the selected family; the caller supplies the -/// visible label. -fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) { - let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { - for face in choices { - if ui.selectable_label(selected == face, face).clicked() { - *selected = face.clone(); - } - } - }); - let shown = selected.clone(); - let access = label.to_string(); - resp.response.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone()); - info.current_text_value = Some(shown.clone()); - info - }); -} - -fn theme_font_payload(d: &mut Dialog) -> Result { - let name = d.get("schemeName", "").trim().to_string(); - if name.is_empty() { - return Err("Enter a scheme name.".into()); - } - let major = d.get("major", "").trim().to_string(); - if major.is_empty() { - return Err("Choose a heading font.".into()); - } - let minor = d.get("minor", "").trim().to_string(); - if minor.is_empty() { - return Err("Choose a body font.".into()); - } - Ok(json!({"name": name, "major": major, "minor": minor})) -} - -/// Create New Theme Fonts. Returns true when the dialog should close. -fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - seed_theme_fonts(d); - let major = d.get("major", "Inter"); - let minor = d.get("minor", "Inter"); - let headings = theme_font_choices(app, &major); - let bodies = theme_font_choices(app, &minor); - let mut major = major; - let mut minor = minor; - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Heading:")); - font_combo(ui, "Heading font", &headings, &mut major); - }); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Body:")); - font_combo(ui, "Body font", &bodies, &mut minor); - }); - d.fields.insert("major".into(), major.clone()); - d.fields.insert("minor".into(), minor.clone()); - ui.add_space(4.0); - // Theme faces are not egui families; naming an unbound family panics. The lines show the - // chosen names at heading and body sizes. - ui.label(egui::RichText::new(format!("Heading sample {major}")).size(20.0)); - ui.label(egui::RichText::new(format!("Body sample {minor}")).size(14.0)); - ui.add_space(4.0); - let mut name = d.get("schemeName", ""); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Name:")); - let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName")); - let shown = name.clone(); - resp.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name"); - info.current_text_value = Some(shown.clone()); - info - }); - }); - d.fields.insert("schemeName".into(), name); - ui.label(egui::RichText::new("Heading and body fonts for this presentation.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); - let (ok, cancel) = buttons(ui, "Save"); - if ok { - match theme_font_payload(d) { - Ok(payload) => return app.run("design.fonts", payload).is_ok(), - Err(msg) => { - app.set_status(msg); - return false; - } - } - } - cancel -} - -/// What Insert Hyperlink would edit on the current selection. -#[derive(Clone, Debug, PartialEq)] -enum CurrentLink { - /// Nothing is linked. OK sets a new link (a caret at a word boundary inserts text). - None, - /// One link covers the text span or every selected shape. - One(deckcraft_model::text::Hyperlink), - /// The selection mixes different links. OK must not replace them until a new target is chosen. - Mixed, -} - -/// Fields the Insert Hyperlink dialog opens with. -#[derive(Clone, Debug, PartialEq)] -struct HyperlinkSeed { - /// `url`, `email` or `slide`. - mode: &'static str, - url: String, - email: String, - slide: usize, - tip: String, - highlight: bool, - /// The action is not a web address, email or slide this dialog edits (or the selection mixes - /// links). OK leaves it in place until the user picks a different target. - preserve: bool, -} - -impl HyperlinkSeed { - /// A new link: the address starts at `https://`, as the dialog always did. - fn blank() -> Self { - HyperlinkSeed { mode: "url", url: "https://".into(), email: String::new(), slide: 0, tip: String::new(), highlight: false, preserve: false } - } -} - -/// Turn one hyperlink into dialog fields. A slide that is no longer in the deck, and actions this -/// dialog does not edit (next slide, a program, …), are preserved so OK does not retarget them. -fn seed_of_link(link: &deckcraft_model::text::Hyperlink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { - use deckcraft_model::text::Action; - let mut seed = HyperlinkSeed { - mode: "url", - url: String::new(), - email: String::new(), - slide: 0, - tip: link.tooltip.clone(), - highlight: link.highlight_click, - preserve: true, - }; - match &link.action { - Action::Url { url } => { - seed.preserve = false; - if let Some(email) = url.strip_prefix("mailto:") { - seed.mode = "email"; - seed.email = email.to_string(); - seed.url = "https://".into(); - } else { - seed.url = url.clone(); - } - } - Action::Slide { slide } => { - if let Some(i) = slide_ids.iter().position(|id| id == slide) { - seed.preserve = false; - seed.mode = "slide"; - seed.slide = i; - seed.url = "https://".into(); - } - } - _ => {} - } - seed -} - -fn seed_from(current: CurrentLink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { - match current { - CurrentLink::None => HyperlinkSeed::blank(), - CurrentLink::Mixed => HyperlinkSeed { preserve: true, url: String::new(), ..HyperlinkSeed::blank() }, - CurrentLink::One(link) => seed_of_link(&link, slide_ids), - } -} - -/// Links on the half-open span `[a, b)`. A point span is a new link: Insert Hyperlink inserts -/// there instead of editing a neighbouring run. -fn link_covering(body: &deckcraft_model::text::TextBody, a: (usize, usize), b: (usize, usize)) -> CurrentLink { - if a == b { - return CurrentLink::None; - } - let mut seen: Option> = None; - for (pi, para) in body.paragraphs.iter().enumerate() { - if pi < a.0 || pi > b.0 { - continue; - } - let from = if pi == a.0 { a.1 } else { 0 }; - let to = if pi == b.0 { b.1 } else { usize::MAX }; - if from >= to { - continue; - } - let mut acc = 0usize; - let mut covered = false; - for run in ¶.runs { - let len = run.char_len(); - let end = acc + len; - if len > 0 && end > from && acc < to { - covered = true; - match &seen { - None => seen = Some(run.props.link.clone()), - Some(prev) if prev == &run.props.link => {} - Some(_) => return CurrentLink::Mixed, - } - } - acc = end; - } - if !covered && para.char_len() == 0 { - match &seen { - None => seen = Some(para.end_props.link.clone()), - Some(prev) if prev == ¶.end_props.link => {} - Some(_) => return CurrentLink::Mixed, - } - } - } - match seen { - Some(Some(link)) => CurrentLink::One(link), - _ => CurrentLink::None, - } -} - -fn common_click(clicks: impl IntoIterator>) -> CurrentLink { - let mut clicks = clicks.into_iter(); - let Some(first) = clicks.next() else { return CurrentLink::None }; - for next in clicks { - if next != first { - return CurrentLink::Mixed; - } - } - match first { - Some(link) => CurrentLink::One(link), - None => CurrentLink::None, - } -} - -/// The link Insert Hyperlink would edit: text when a caret or selection is active, otherwise the -/// click action shared by the selected shapes. -fn hyperlink_seed_of(st: Option<&deckcraft_engine::DocState>) -> HyperlinkSeed { - let Some(st) = st else { return HyperlinkSeed::blank() }; - let ids: Vec = st.doc.slides.iter().map(|s| s.id).collect(); - let current = if let Some(t) = &st.selection.text { - match deckcraft_engine::cmd::text::body_of(st, t) { - Some(body) => { - let (a, b) = deckcraft_engine::cmd::text::format_range(st, t); - link_covering(body, a, b) - } - None => CurrentLink::None, - } - } else { - common_click(st.selected_shapes().into_iter().map(|sh| sh.click.clone())) - }; - seed_from(current, &ids) -} - -/// OK writes a link unless the dialog is holding an action it does not edit and the user has not -/// chosen a new target. -fn hyperlink_should_apply(seed: &HyperlinkSeed, mode: &str, url: &str, email: &str, slide: usize) -> bool { - if !seed.preserve { - return true; - } - if mode != seed.mode { - return true; - } - match mode { - "email" => email != seed.email, - "slide" => slide != seed.slide, - _ => url != seed.url, - } -} - -fn hyperlink_command(mode: &str, url: &str, email: &str, slide: usize, tip: &str, highlight: bool) -> Value { - match mode { - "slide" => json!({"slide": slide, "tooltip": tip, "highlightClick": highlight}), - "email" => json!({"url": format!("mailto:{email}"), "tooltip": tip, "highlightClick": highlight}), - _ => json!({"url": url, "tooltip": tip, "highlightClick": highlight}), - } -} - -/// Preset percents in the Zoom dialog, largest first. -const ZOOM_PRESETS: &[u32] = &[400, 200, 150, 100, 75, 66, 50, 33]; - -/// A typed zoom percent as a scale in `0.1..=4` (10%–400%). `None` when it is not a finite number. -pub fn parse_zoom_percent(text: &str) -> Option { - let t = text.trim().trim_end_matches('%').trim(); - if t.is_empty() { - return None; - } - let v = t.parse::().ok().filter(|n| n.is_finite())?; - Some(((v / 100.0) as f32).clamp(0.1, 4.0)) -} - -/// Zoom dialog: a percent or Fit. Nothing changes until OK. -fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - if !d.fields.contains_key("mode") { - let shown = app.ui.zoom.unwrap_or(app.canvas_scale); - let pct = if shown.is_finite() { (shown * 100.0).round().clamp(10.0, 400.0) } else { 100.0 }; - d.fields.insert("mode".into(), if app.ui.zoom.is_none() { "fit".into() } else { "percent".into() }); - d.fields.insert("percent".into(), format!("{pct:.0}")); - } - let mut mode = d.get("mode", "percent"); - let mut percent = d.get("percent", "100"); - ui.label("Zoom to"); - if ui.radio(mode == "fit", "Fit").clicked() { - mode = "fit".into(); - } - for pct in ZOOM_PRESETS { - let on = mode == "percent" && percent == pct.to_string(); - if ui.radio(on, format!("{pct}%")).clicked() { - mode = "percent".into(); - percent = pct.to_string(); - } - } - ui.horizontal(|ui| { - let l = ui.label("Percent:"); - let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id); - if edit.changed() { - mode = "percent".into(); - } - ui.label("%"); - }); - d.fields.insert("mode".into(), mode.clone()); - d.fields.insert("percent".into(), percent.clone()); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - if mode == "fit" { - app.ui.zoom = None; - return true; - } - match parse_zoom_percent(&percent) { - Some(scale) => app.ui.zoom = Some(scale), - None => { - app.set_status("Enter a zoom percent"); - return false; - } - } - } - ok || cancel -} - -/// Seconds for a Trim Media field. Three decimal places, trailing zeros dropped, so the text -/// round-trips the millisecond value the clip stores. -fn ms_as_seconds(ms: u32) -> String { - let s = format!("{:.3}", f64::from(ms) / 1000.0); - let trimmed = s.trim_end_matches('0').trim_end_matches('.'); - if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() } -} - -/// Parse a seconds field back to milliseconds. Blank, junk and negatives become 0; the result is -/// rounded so `0.333` is 333 ms, not 332. -fn seconds_text_to_ms(text: &str) -> u32 { - let v = text.trim().parse::().ok().filter(|v| v.is_finite()).unwrap_or(0.0); - let ms = (v.max(0.0) * 1000.0).round(); - if ms >= f64::from(u32::MAX) { u32::MAX } else { ms as u32 } -} - -/// Start, end trim, fade in and fade out, as the Trim Media dialog shows them. -struct TrimSeed { - start: String, - end: String, - fade_in: String, - fade_out: String, -} - -impl TrimSeed { - fn zeros() -> Self { - TrimSeed { start: "0".into(), end: "0".into(), fade_in: "0".into(), fade_out: "0".into() } - } - fn from_clip(m: &deckcraft_model::MediaClip) -> Self { - TrimSeed { - start: ms_as_seconds(m.trim_start_ms), - end: ms_as_seconds(m.trim_end_ms), - fade_in: ms_as_seconds(m.fade_in_ms), - fade_out: ms_as_seconds(m.fade_out_ms), - } - } -} - -/// The first selected audio or video. Other shapes in the selection are skipped. No media means -/// the old zeros, which OK then stores. -fn trim_seed(st: Option<&deckcraft_engine::DocState>) -> TrimSeed { - let Some(st) = st else { return TrimSeed::zeros() }; - st.selected_shapes() - .into_iter() - .find_map(|sh| match &sh.kind { - deckcraft_model::ShapeKind::Media(m) => Some(TrimSeed::from_clip(m)), - _ => None, - }) - .unwrap_or_else(TrimSeed::zeros) -} - -/// Returns true when the dialog should close. -fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let run = |app: &mut SlideApp, id: &str, p: Value| { - let _ = app.run(id, p); - }; - match d.id.as_str() { - "table" => { - field(ui, d, "Number of columns:", "cols", "5"); - field(ui, d, "Number of rows:", "rows", "2"); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); - run(app, "insert.table", json!({"rows": r, "cols": c})); - } - ok || cancel - } - "slideSize" => { - let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - let len = app.session.prefs.units; - let ppu = len.points_per_unit(); - // Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so - // a preset, Portrait or an untouched field is not nudged off by the rounding (13.33", - // 33.87 cm read back would make the slide 959.76 or 960.09 pt wide). - if !d.fields.contains_key("wPt") { - put_slide_dim(d, "w", cur.width, ppu); - put_slide_dim(d, "h", cur.height, ppu); - } - let label = ui.label("Slides sized for:"); - let preset = d.get("preset", ""); - let combo = egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { - for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { - if ui.selectable_label(preset == *label, *label).clicked() { - d.fields.insert("preset".into(), label.to_string()); - put_slide_dim(d, "w", *w, ppu); - put_slide_dim(d, "h", *h, ppu); - } - } - }); - let _ = combo.response.labelled_by(label.id); - let abbr = unit_abbr(len); - let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); - field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); - field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); - let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); - ui.horizontal(|ui| { - ui.label("Orientation:"); - let portrait = ui.radio(h > w, "Portrait").clicked(); - let landscape = ui.radio(w >= h, "Landscape").clicked(); - // Picking the other orientation swaps the width and height fields. - if portrait && w > h || landscape && h > w { - put_slide_dim(d, "w", h, ppu); - put_slide_dim(d, "h", w, ppu); - } - }); - let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); - run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); - } - ok || cancel - } - "sizePosition" => size_position_dialog(app, ui, d), - "headerFooter" => { - let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); - let date = check(ui, d, "Date and time", "date", hf.date); - field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); - // The format applies to an automatic date; a fixed one is the text as typed. - let automatic = d.get("dateText", &hf.date_text).is_empty(); - let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; - // Not `d.get`: that stores the default as if the user had picked it. - let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone()); - let rows = date_rows("en-US"); - let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); - ui.add_enabled_ui(date && automatic, |ui| { - ui.horizontal(|ui| { - ui.label(" Format:"); - egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { - for (kind, sample) in &rows { - if ui.selectable_label(fmt == *kind, sample).clicked() { - d.fields.insert("dateFormat".into(), (*kind).to_string()); - } - } - }); - }); - }); - let num_on = check(ui, d, "Slide number", "num", hf.slide_number); - let foot = check(ui, d, "Footer", "footer", hf.footer); - field(ui, d, " Footer text:", "footerText", &hf.footer_text); - let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); - ui.add_space(6.0); - let mut apply_all = false; - let mut apply = false; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - apply_all = ui.button("Apply to All").clicked(); - apply = ui.button("Apply").clicked(); - if ui.button("Cancel").clicked() { - d.fields.insert("cancel".into(), "1".into()); - } - }); - if apply || apply_all { - let dt = d.get("dateText", ""); - let ft = d.get("footerText", ""); - let mut params = serde_json::Map::new(); - params.insert("date".into(), json!(date)); - params.insert("dateText".into(), json!(dt)); - // Only a format the user picked: sending the default would rewrite every slide's - // existing date field (an imported datetime4, say) to datetime1. - if d.fields.contains_key("dateFormat") { - params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); - } - params.insert("slideNumber".into(), json!(num_on)); - params.insert("footer".into(), json!(foot)); - params.insert("footerText".into(), json!(ft)); - params.insert("hideOnTitle".into(), json!(hide)); - params.insert("all".into(), json!(apply_all)); - run(app, "design.headerFooter", serde_json::Value::Object(params)); - } - apply || apply_all || d.fields.contains_key("cancel") - } - "hyperlink" => { - let seed = hyperlink_seed_of(app.session.active()); - let slide_default = seed.slide.to_string(); - let mode = d.get("mode", seed.mode); - ui.horizontal(|ui| { - if ui.selectable_label(mode == "url", "Web Page or File").clicked() { - d.fields.insert("mode".into(), "url".into()); - } - if ui.selectable_label(mode == "slide", "This Document").clicked() { - d.fields.insert("mode".into(), "slide".into()); - } - if ui.selectable_label(mode == "email", "Email Address").clicked() { - d.fields.insert("mode".into(), "email".into()); - } - }); - match mode.as_str() { - "slide" => { - let titles: Vec = app - .session - .active() - .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) - .unwrap_or_default(); - let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - for (i, t) in titles.iter().enumerate() { - if ui.selectable_label(sel == i, t).clicked() { - d.fields.insert("slide".into(), i.to_string()); - } - } - }); - } - "email" => field(ui, d, "Email address:", "email", &seed.email), - _ => field(ui, d, "Address:", "url", &seed.url), - } - field(ui, d, "ScreenTip:", "tip", &seed.tip); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let mode_now = d.get("mode", seed.mode); - let url = d.get("url", &seed.url); - let email = d.get("email", &seed.email); - let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); - let tip = d.get("tip", &seed.tip); - if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { - run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); - } - } - ok || cancel - } - "setupShow" => { - let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); - let ty = d.get("type", &sh.show_type); - ui.label("Show type"); - for (l, v) in [ - ("Presented by a speaker (full screen)", "speaker"), - ("Browsed by an individual (window)", "browsed"), - ("Browsed at a kiosk (full screen)", "kiosk"), - ] { - if ui.radio(ty == v, l).clicked() { - d.fields.insert("type".into(), v.into()); - } - } - let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); - let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); - let na = check(ui, d, "Show without animation", "na", sh.without_animation); - let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); - let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); - let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); - let field = ui.label("Slides"); - let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; - let combo = egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { - d.fields.insert("custom".into(), String::new()); - } - for n in &names { - if ui.selectable_label(&chosen == n, n).clicked() { - d.fields.insert("custom".into(), n.clone()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let ty = d.get("type", "speaker"); - let custom = d.fields.get("custom").cloned().unwrap_or_default(); - run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); - } - ok || cancel - } - "quit" => { - let names: Vec = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect(); - if names.is_empty() { - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - return true; - } - let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); - ui.label("Your changes will be lost if you don't save them."); - ui.add_space(8.0); - let mut done = false; - ui.horizontal(|ui| { - let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { - let _ = app.session.execute("file.recovery.discard", &json!({})); - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - done = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); - if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - if app.save_all() { - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - done = true; - } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { - done = true; - } - }); - }); - done - } - "close" => { - // File › Close and Ctrl+W on a presentation with unsaved changes. - let Some(name) = app.session.active().map(|d| d.title()) else { return true }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong()); - ui.label("Your changes will be lost if you don't save them."); - ui.add_space(8.0); - let mut done = false; - ui.horizontal(|ui| { - let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { - run(app, "file.close", json!({})); - done = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); - if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - app.save(); - // In the browser a save is a download: the presentation stays "unsaved". - let web = app.services.pick_save.is_none() && app.services.download.is_some(); - if web || app.session.active().is_some_and(|d| !d.is_dirty()) { - run(app, "file.close", json!({})); - } - done = true; - } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { - done = true; - } - }); - }); - done - } - "zoom" => zoom_dialog(app, ui, d), - "comment" => { - let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Post"); - if ok && !v.trim().is_empty() { - run(app, "comment.add", json!({"text": v})); - app.ui.pane = Some("comments".into()); - } - ok || cancel - } - "bullets" => { - // The current paragraph's list, as the dialog's starting point. - let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); - let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); - let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); - ui.horizontal(|ui| { - for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { - if ui.selectable_label(tab == k, l).clicked() { - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - let style_key = if tab == "numbered" { "scheme" } else { "char" }; - let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" }); - let styles: &[(&str, &str)] = if tab == "numbered" { - &[ - ("arabicPeriod", "1. 2. 3."), - ("arabicParenR", "1) 2) 3)"), - ("romanUcPeriod", "I. II. III."), - ("romanLcPeriod", "i. ii. iii."), - ("alphaUcPeriod", "A. B. C."), - ("alphaLcParenR", "a) b) c)"), - ("alphaLcPeriod", "a. b. c."), - ] - } else { - &[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")] - }; - egui::Grid::new("bullet-styles").show(ui, |ui| { - for (i, (k, l)) in styles.iter().enumerate() { - let r = ui.add_sized( - vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), - egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), - ); - if r.clicked() { - d.fields.insert(style_key.into(), (*k).into()); - } - if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { - ui.end_row(); - } - } - }); - if tab == "bulleted" { - field(ui, d, "Custom character:", "custom", ""); - } else { - field(ui, d, "Start at:", "start", "1"); - } - field(ui, d, "Size (% of text):", "size", "100"); - let colors = [ - ("", "Automatic"), - ("tx1", "Text"), - ("accent1", "Accent 1"), - ("accent2", "Accent 2"), - ("accent3", "Accent 3"), - ("accent4", "Accent 4"), - ("accent5", "Accent 5"), - ("accent6", "Accent 6"), - ]; - let color = d.get("color", ""); - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); - let combo = egui::ComboBox::from_id_salt("bullet-color") - .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) - .show_ui(ui, |ui| { - for (k, l) in colors { - if ui.selectable_label(color == k, l).clicked() { - d.fields.insert("color".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - ui.horizontal(|ui| { - if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { - d.fields.insert("none".into(), "1".into()); - } - }); - let (ok, cancel) = buttons(ui, "OK"); - let none = d.fields.contains_key("none"); - if none { - run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); - } else if ok { - let mut p = json!({"on": true}); - if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { - p["size"] = json!(size.clamp(25.0, 400.0)); - } - if !color.is_empty() { - p["color"] = json!(color); - } - if tab == "numbered" { - p["scheme"] = json!(d.get("scheme", "arabicPeriod")); - p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); - run(app, "format.numbering", p); - } else { - let custom = d.get("custom", ""); - let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•")); - p["char"] = json!(ch); - run(app, "format.bullets", p); - } - } - ok || cancel || none - } - "action" => { - use deckcraft_model::text::Action; - let tab = d.get("tab", "click"); - // Start from the selected shape's current setting for this tab. - let (cur, slides, shows) = app - .session - .active() - .map(|st| { - let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); - let slides: Vec<(deckcraft_model::SlideId, String)> = - st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); - let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); - (sh, slides, shows) - }) - .unwrap_or_default(); - let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { - None => ("none", String::new()), - Some(Action::NextSlide) => ("next", String::new()), - Some(Action::PreviousSlide) => ("previous", String::new()), - Some(Action::FirstSlide) => ("first", String::new()), - Some(Action::LastSlide) => ("last", String::new()), - Some(Action::LastViewed) => ("lastViewed", String::new()), - Some(Action::EndShow) => ("endShow", String::new()), - Some(Action::PlayMedia) => ("playMedia", String::new()), - Some(Action::Url { url }) => ("url", url.clone()), - Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), - Some(Action::Program { path }) => ("program", path.clone()), - Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), - }; - ui.horizontal(|ui| { - for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { - if ui.selectable_label(tab == k, l).clicked() && tab != k { - d.fields.clear(); - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); - let kind = d.get("kind", cur_kind); - let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); - let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { - if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - }; - pick(d, ui, "none", "None"); - ui.horizontal(|ui| { - let field = ui.radio(hyperlink, "Hyperlink to:"); - if field.clicked() && !hyperlink { - d.fields.insert("kind".into(), "next".into()); - } - let targets = [ - ("next", "Next Slide"), - ("previous", "Previous Slide"), - ("first", "First Slide"), - ("last", "Last Slide"), - ("lastViewed", "Last Slide Viewed"), - ("endShow", "End Show"), - ("customShow", "Custom Show…"), - ("slide", "Slide…"), - ("url", "URL…"), - ]; - let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); - ui.add_enabled_ui(hyperlink, |ui| { - let combo = egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { - for (k, l) in targets { - if ui.selectable_label(kind == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - }); - let kind = d.get("kind", cur_kind); - match kind.as_str() { - "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), - "slide" => { - let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); - egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { - for (i, (_, t)) in slides.iter().enumerate() { - if ui.selectable_label(sel == i, t).clicked() { - d.fields.insert("arg".into(), i.to_string()); - } - } - }); - } - "customShow" => { - if shows.is_empty() { - ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); - } - let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); - for n in &shows { - if ui.selectable_label(&sel == n, n).clicked() { - d.fields.insert("arg".into(), n.clone()); - } - } - } - _ => {} - } - pick(d, ui, "program", "Run program:"); - if d.get("kind", cur_kind) == "program" { - field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); - } - pick(d, ui, "playMedia", "Play the shape's media"); - let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let kind = d.get("kind", cur_kind); - let arg = d.get("arg", &cur_arg); - let mut p = json!({"on": tab, "action": kind, "highlight": hl}); - match kind.as_str() { - "url" => p["url"] = json!(arg), - "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), - "customShow" => p["show"] = json!(arg), - "program" => p["path"] = json!(arg), - _ => {} - } - run(app, "shape.action", p); - } - ok || cancel - } - "altText" => { - let (cur, deco_now) = app - .session - .active() - .map(|s| { - let shapes = s.selected_shapes(); - let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); - let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); - (cur, deco) - }) - .unwrap_or_default(); - ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); - let mut v = d.get("text", &cur); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); - d.fields.insert("text".into(), v.clone()); - let deco = check(ui, d, "Mark as decorative", "deco", deco_now); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run(app, "shape.altText", json!({"text": v, "decorative": deco})); - } - ok || cancel - } - "outline" => { - ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); - let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - run(app, "slide.fromOutline", json!({"text": v})); - } - ok || cancel - } - "reuseSlides" => { - let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); - let slides: Vec<(usize, String)> = d - .params - .get("slides") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|x| { - Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) - }) - .collect() - }) - .unwrap_or_default(); - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); - ui.label(format!("Insert slides from {name} after the current slide:")); - // Unticked slides are remembered by index; everything starts ticked. - let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); - ui.horizontal(|ui| { - if ui.small_button("Select All").clicked() { - for (i, _) in &slides { - d.fields.remove(&format!("off{i}")); - } - } - if ui.small_button("Select None").clicked() { - for (i, _) in &slides { - d.fields.insert(format!("off{i}"), "1".into()); - } - } - }); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, title) in &slides { - let mut on = !off(d, *i); - let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; - if ui.checkbox(&mut on, label).changed() { - if on { - d.fields.remove(&format!("off{i}")); - } else { - d.fields.insert(format!("off{i}"), "1".into()); - } - } - } - }); - let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); - if ui.checkbox(&mut keep, "Keep source formatting").changed() - && let Some(o) = d.params.as_object_mut() - { - o.insert("keep".into(), json!(keep)); - } - let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - if picked.is_empty() { - app.set_status("Tick at least one slide to insert".to_string()); - return false; - } - // A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar. - let source = d.params.get("source").cloned().unwrap_or_default(); - return app.reuse_slides(&source, keep, &picked); - } - cancel - } - "renameSection" | "renameShape" | "renameLayout" => { - let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - field(ui, d, "Name:", "name", &cur); - let (ok, cancel) = buttons(ui, "Rename"); - if ok { - let name = d.get("name", ""); - match d.id.as_str() { - "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), - "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), - _ => run(app, "master.renameLayout", json!({"name": name})), - } - } - ok || cancel - } - "font" => font_dialog(app, ui, d), - "paragraph" => paragraph_dialog(app, ui, d), - "tabs" => tabs_dialog(app, ui, d), - "export" => { - ui.label("Export the presentation as:"); - let fmt = d.get("fmt", "pdf"); - for (l, f) in [ - ("PDF document (.pdf)", "pdf"), - ("PNG images (current slide)", "png"), - ("PNG images (all slides)", "pngAll"), - ("JPEG image (current slide)", "jpeg"), - ("PowerPoint Presentation (.pptx)", "pptx"), - ("Outline (.txt)", "outline"), - ("Animated GIF (.gif)", "gif"), - ("Outline/RTF (.rtf)", "rtf"), - ] { - if ui.radio(fmt == f, l).clicked() { - d.fields.insert("fmt".into(), f.into()); - } - } - let pdf_layout = d.get("pdfLayout", "slides"); - if fmt == "pdf" { - ui.add_space(4.0); - ui.horizontal(|ui| { - let field = ui.label("Print layout:"); - let combo = egui::ComboBox::from_id_salt("pdf_layout") - .selected_text(match pdf_layout.as_str() { - "notes" => "Notes Pages", - "h1" => "Handouts (1 slide per page)", - "h2" => "Handouts (2 slides per page)", - "h3" => "Handouts (3 slides per page)", - "h4" => "Handouts (4 slides per page)", - "h6" => "Handouts (6 slides per page)", - "h9" => "Handouts (9 slides per page)", - _ => "Full Page Slides", - }) - .show_ui(ui, |ui| { - for (k, l) in [ - ("slides", "Full Page Slides"), - ("notes", "Notes Pages"), - ("h1", "Handouts (1 slide per page)"), - ("h2", "Handouts (2 slides per page)"), - ("h3", "Handouts (3 slides per page)"), - ("h4", "Handouts (4 slides per page)"), - ("h6", "Handouts (6 slides per page)"), - ("h9", "Handouts (9 slides per page)"), - ] { - if ui.selectable_label(pdf_layout == k, l).clicked() { - d.fields.insert("pdfLayout".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - let mut hidden = d.get("pdfHidden", "false") == "true"; - if ui.checkbox(&mut hidden, "Include hidden slides").changed() { - d.fields.insert("pdfHidden".into(), hidden.to_string()); - } - } - let png = matches!(fmt.as_str(), "png" | "pngAll"); - if png { - ui.add_space(4.0); - let mut clear = d.get("pngTransparent", "false") == "true"; - if ui - .checkbox(&mut clear, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn") - .changed() - { - d.fields.insert("pngTransparent".into(), clear.to_string()); - } - } - let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; - if let Err(e) = &gif_params { - ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); - } - let pdf_params = { - let (layout, per) = match pdf_layout.as_str() { - "notes" => ("notes", 0), - l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), - _ => ("slides", 0), - }; - json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) - }; - let (ok, cancel) = buttons(ui, "Export"); - if ok { - let gif_params = match gif_params { - Ok(v) => v, - // Keep the dialog open so the range or width can be corrected. - Err(e) => { - app.set_status(e); - return false; - } - }; - let extra = match fmt.as_str() { - "pdf" => pdf_params, - "gif" => gif_params, - _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), - _ => json!({}), - }; - app.export_as(&fmt, extra); - } - ok || cancel - } - "replaceFonts" => { - let used: Vec = - app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); - let mut to = d.get("to", ""); - egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let field = ui.label("Replace:"); - let combo = egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { - for f in &used { - ui.selectable_value(&mut from, f.clone(), f); - } - }); - let _ = combo.response.labelled_by(field.id); - ui.end_row(); - ui.label("With:"); - ui.vertical(|ui| { - ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); - let needle = to.trim().to_lowercase(); - egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { - for f in - crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) - { - if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { - to = f.clone(); - } - } - }); - }); - ui.end_row(); - }); - if let Some(msg) = d.fields.get("result") { - ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); - } - d.fields.insert("from".into(), from.clone()); - d.fields.insert("to".into(), to.clone()); - let (ok, cancel) = buttons(ui, "Replace"); - if ok && !from.is_empty() && !to.trim().is_empty() { - let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); - let msg = - if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; - d.fields.insert("result".into(), msg); - d.fields.remove("from"); - // Stay open, like PowerPoint, so several fonts can be replaced in a row. - return cancel; - } - cancel - } - "symbol" => { - ui.label("Click a symbol to insert it at the insertion point."); - let groups: [(&str, &str); 6] = [ - ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), - ("Currency", "$€£¥¢₹₩₽₺₿"), - ("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"), - ("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"), - ("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"), - ("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"), - ]; - for (g, chars) in groups { - ui.label(egui::RichText::new(g).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for c in chars.chars() { - if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { - if app.session.active().is_some_and(|s| s.selection.text.is_none()) { - let _ = app.run("text.edit", json!({})); - } - let _ = app.run("insert.symbol", json!({"text": c.to_string()})); - } - } - }); - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "equation" => { - ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); - let mut v = d.get("eq", ""); - ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); - d.fields.insert("eq".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok && !v.is_empty() { - let pretty = pretty_equation(&v); - let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); - run(app, "text.exit", json!({})); - run(app, "format.font", json!({"family": "Liberation Serif"})); - run(app, "format.italic", json!({"on": true})); - run(app, "format.size", json!({"size": 28})); - } - ok || cancel - } - "pasteSpecial" => { - const OPTS: [(&str, &str); 4] = [ - ("destinationTheme", "Use Destination Theme"), - ("keepSource", "Keep Source Formatting"), - ("picture", "Picture (PNG)"), - ("text", "Keep Text Only"), - ]; - let has_shapes = !app.session.clipboard.shapes.is_empty(); - let mut cur = d.get("as", "destinationTheme"); - ui.label("As:"); - for (id, label) in OPTS { - let ok = has_shapes || matches!(id, "destinationTheme" | "text"); - ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); - } - d.fields.insert("as".into(), cur.clone()); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run(app, "edit.pasteSpecial", json!({"as": cur})); - } - ok || cancel - } - "find" => find_dialog(app, ui, d), - "spelling" => { - // Words skipped with Ignore All while the dialog is open, one per line. - let ignored = d.get("ignore", ""); - let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); - let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); - let list = v.as_array().cloned().unwrap_or_default(); - let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); - let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); - match info.get("found").and_then(Value::as_str) { - Some(f) if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) - } - _ if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) - } - _ => ui.label( - egui::RichText::new(format!( - "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", - info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), - info.get("folders") - .and_then(Value::as_array) - .and_then(|a| a.get(1).or(a.first())) - .and_then(Value::as_str) - .unwrap_or("a dictionaries folder") - )) - .small() - .weak(), - ), - }; - if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); - } - let mut add_ignore = None; - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for item in &list { - let w = item.get("word").and_then(Value::as_str).unwrap_or(""); - let slide = item.get("slide").cloned().unwrap_or_default(); - let shape = item.get("shape").cloned().unwrap_or_default(); - ui.horizontal(|ui| { - ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": slide})); - let _ = app.run("edit.select", json!({"ids": [shape]})); - } - if ui.small_button("Ignore All").clicked() { - add_ignore = Some(w.to_string()); - } - if ui.small_button("Add to Dictionary").clicked() - && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) - { - app.set_status(e.to_string()); - } - }); - let sug: Vec = item - .get("suggestions") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_default(); - if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) - { - ui.horizontal(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new("Repeated word").weak()); - if ui.small_button("Delete Repeated Word").clicked() { - let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; - let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; - let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; - let text = app.session.active().and_then(|st| { - let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; - let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { - (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { - tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() - } - _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), - }; - Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) - }); - if let Some(gap) = text { - let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); - if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { - o.insert("row".into(), r.clone()); - o.insert("col".into(), c.clone()); - } - run(app, "review.spellingChange", p); - } - } - }); - } - if !sug.is_empty() { - ui.horizontal_wrapped(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new("Change to:").weak()); - for to in &sug { - let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); - if r.clicked() { - let all = ui.input(|i| i.modifiers.shift); - if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { - // A word in the notes: change that line of the slide's notes. - let si = slide.as_u64().unwrap_or(0) as usize; - let notes = - app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); - let (pi, st) = ( - item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, - item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, - ); - let lines: Vec = notes - .split('\n') - .enumerate() - .map(|(i, l)| { - if i != pi { - return l.to_string(); - } - let c: Vec = l.chars().collect(); - let n = w.chars().count(); - if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { - c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() - } else { - l.to_string() - } - }) - .collect(); - run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); - } else { - let mut p = if all { - json!({"word": w, "to": to, "all": true}) - } else { - json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) - }; - if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { - o.insert("row".into(), r.clone()); - o.insert("col".into(), c.clone()); - } - run(app, "review.spellingChange", p); - } - } - } - }); - } - } - }); - if let Some(w) = add_ignore { - d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "accessibility" => { - let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); - let list = v.as_array().cloned().unwrap_or_default(); - if list.is_empty() { - ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for item in &list { - ui.horizontal(|ui| { - ui.label(format!( - "Slide {}: {}{}", - item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, - item.get("issue").and_then(Value::as_str).unwrap_or(""), - item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() - )); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); - if let Some(id) = item.get("shape") { - let _ = app.run("edit.select", json!({"ids": [id]})); - } - } - }); - } - }); - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "language" => { - // Starts from the selected text's language and Do Not Check Spelling. - let (cur, np) = selected_language(app); - let lang = d.get("lang", &cur); - let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; - // The selected text, or every text in the presentation (all there is with nothing selected). - let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); - let mut all = !has_sel || d.get("scope", "") == "all"; - ui.horizontal(|ui| { - ui.label("Apply to:"); - if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { - d.fields.insert("scope".into(), "sel".into()); - all = false; - } - if ui.radio(all, "Whole presentation").clicked() { - d.fields.insert("scope".into(), "all".into()); - all = true; - } - }); - ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - // The selection's own language first when the list doesn't have it. - let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); - let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); - for (_native, tag) in all { - // Native names are shown when the interface fonts can draw them; otherwise - // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). - let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); - let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); - let label = if installed { format!("{name} (dictionary)") } else { name }; - if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { - d.fields.insert("lang".into(), tag); - } - } - }); - ui.add_space(4.0); - ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); - if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { - d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); - } - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let lang = d.get("lang", &cur); - run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof})); - } - ok || cancel - } - "customShow" => { - let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); - let mut started = false; - for s in &shows { - ui.horizontal(|ui| { - ui.label(format!("{} ({} slides)", s.name, s.slides.len())); - if ui.small_button("Show").clicked() { - run(app, "show.start", json!({"custom": s.name})); - started = true; - } - if ui.small_button("Delete").clicked() { - let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); - } - }); - } - if started { - return true; - } - field(ui, d, "New show name:", "name", "Custom Show 1"); - field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); - let (ok, cancel) = buttons(ui, "Create"); - if ok { - let idx: Vec = - d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); - run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); - } - ok || cancel - } - "chartData" => { - let ch = app.session.active().and_then(|s| { - s.selected_shapes() - .into_iter() - .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) - }); - let Some((c, id)) = ch else { - ui.label("Select a chart first."); - let (ok, cancel) = buttons(ui, "Close"); - return ok || cancel; - }; - ui.label("Edit the data: first row = series names, first column = categories."); - let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { - let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; - for (i, cat) in c.categories.iter().enumerate() { - let mut row = vec![cat.clone()]; - for s in &c.series { - row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); - } - g.push(row); - } - g - }); - egui::Grid::new("chartdata").striped(true).show(ui, |ui| { - for row in grid.iter_mut() { - for cell in row.iter_mut() { - ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); - } - ui.end_row(); - } - }); - ui.horizontal(|ui| { - if ui.button("+ Row").clicked() { - let n = grid.first().map(Vec::len).unwrap_or(2); - grid.push(vec![String::new(); n]); - } - if ui.button("+ Series").clicked() { - for r in grid.iter_mut() { - r.push(String::new()); - } - } - }); - d.params = json!({"grid": grid}); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); - let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); - let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); - run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); - } - ok || cancel - } - "trim" => { - let seed = trim_seed(app.session.active()); - field(ui, d, "Start time (s):", "start", &seed.start); - field(ui, d, "End trim (s):", "end", &seed.end); - field(ui, d, "Fade in (s):", "fin", &seed.fade_in); - field(ui, d, "Fade out (s):", "fout", &seed.fade_out); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "media.options", - json!({ - "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), - "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), - "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), - "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), - }), - ); - } - ok || cancel - } - "smartart" => { - ui.label("Pick a graphic; type one item per line."); - let kind = d.get("kind", "process"); - ui.horizontal_wrapped(|ui| { - for (k, l) in [ - ("list", "Basic List"), - ("process", "Basic Process"), - ("cycle", "Basic Cycle"), - ("hierarchy", "Hierarchy"), - ("pyramid", "Pyramid"), - ("matrix", "Matrix"), - ] { - if ui.selectable_label(kind == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - } - }); - let mut v = d.get("text", "Plan\nBuild\nLaunch"); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); - app.ui.pane = Some("smartart".into()); - } - ok || cancel - } - "about" => { - let tab = d.get("tab", "about"); - ui.horizontal(|ui| { - for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { - if ui.selectable_label(tab == k, l).clicked() { - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - match tab.as_str() { - "contributors" | "models" => { - credits_block(ui, tab == "models"); - } - _ => about(app, ui), - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "autocorrect" => { - let pr = &mut app.session.prefs; - ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); - ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); - ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); - ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); - ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); - ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); - ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); - ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); - ui.add_space(4.0); - let mut from = d.get("from", ""); - let mut to = d.get("to", ""); - ui.horizontal(|ui| { - let l = ui.label("Replace"); - ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); - let l = ui.label("With"); - ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); - if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { - run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); - from.clear(); - to.clear(); - } - }); - d.fields.insert("from".into(), from.clone()); - d.fields.insert("to".into(), to); - let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); - let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let q = from.trim().to_lowercase(); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - egui::Grid::new("ac-list").striped(true).show(ui, |ui| { - for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { - ui.label(k); - ui.label(v); - if ui.small_button("Delete").clicked() { - run(app, "text.autocorrect", json!({"remove": k})); - } - ui.end_row(); - } - }); - }); - let (ok, cancel) = buttons(ui, "OK"); - ok || cancel - } - "gridOptions" => grid_options(app, ui, d), - "columns" => columns_dialog(app, ui, d), - "preferences" => { - // The controls apply as they change, so the dialog remembers what it opened with and - // Cancel (or Esc, or the close button: see `show`) puts that back. - if d.params.get("before").is_none() { - d.params = json!({ "before": preferences_snapshot(app) }); - } - ui.label(egui::RichText::new("General").font(theme::bold(13.0))); - let mut dark = app.ui.brightness == theme::Brightness::Dark; - if ui.checkbox(&mut dark, "Dark appearance").changed() { - let _ = app.run("view.dark", json!({"on": dark})); - } - // While the pointer holds the slider its value only waits in the dialog: applying it - // would rescale the slider under the pointer and run it to an end (#767). It applies - // on release, and at once from the keyboard. - let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); - let mut scale = pending.unwrap_or(app.ui.ui_scale); - let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); - // No visible label here (the row is laid out to fit); the accessible name only. - let enabled = r.enabled(); - r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); - if r.is_pointer_button_down_on() { - if r.changed() || pending.is_some() { - d.fields.insert("uiScale".into(), scale.to_string()); - } - } else { - if r.changed() || pending.is_some() { - app.ui.ui_scale = scale; - } - d.fields.remove("uiScale"); - } - // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. - ui.horizontal(|ui| { - ui.label("Measurement units:"); - let units = &mut app.session.prefs.units; - ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); - ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); - }); - ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); - ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); - ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); - ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); - ui.horizontal(|ui| { - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); - if ui.button("AutoCorrect Options…").clicked() { - *d = Dialog::new("autocorrect"); - } - }); - if d.id != "preferences" { - return false; - } - ui.horizontal(|ui| { - let label = ui.label("Your name (comments):"); - // Name the field for assistive technology; a bare text edit next to a label is an - // unnamed input in egui 0.37's accessibility tree. - let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); - }); - let (ok, cancel) = buttons(ui, "Done"); - if cancel { - restore_preferences(app, d); - } - ok || cancel - } - "photoAlbum" => photo_album_dialog(app, ui, d), - "themeColors" => theme_colors_dialog(app, ui, d), - "themeFonts" => theme_fonts_dialog(app, ui, d), - "dateTime" => date_time_dialog(app, ui, d), - _ => { - ui.label(format!("“{}” is not available yet.", d.id)); - let (ok, cancel) = buttons(ui, "OK"); - ok || cancel - } - } -} - fn num_text(v: f64) -> String { crate::widgets::fmt_num(v, "") } @@ -2422,19 +182,6 @@ fn parse_shown(s: &str) -> Option { t.parse().ok().filter(|v: &f64| v.is_finite()) } -fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { - d.fields.insert(key.into(), shown.clone()); - d.fields.insert(format!("seed{key}"), shown.clone()); - d.fields.insert(format!("prev{key}"), shown); - d.fields.insert(format!("{key}Pt"), exact.to_string()); -} - -fn publish_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { - d.fields.insert(key.into(), shown.clone()); - d.fields.insert(format!("seed{key}"), shown); - d.fields.insert(format!("{key}Pt"), exact.to_string()); -} - fn edited(d: &Dialog, key: &str) -> bool { field_of(d, key) != field_of(d, &format!("seed{key}")) } @@ -2443,171 +190,6 @@ fn exact_of(d: &Dialog, key: &str) -> Option { parse_shown(&field_of(d, &format!("{key}Pt"))) } -fn selection_xfrm(app: &SlideApp) -> Option { - let st = app.session.active()?; - let id = *st.selection.shapes.first()?; - let sh = st.shape(id)?; - Some(deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)) -} - -fn seed_size_position(app: &SlideApp, d: &mut Dialog) { - if d.fields.contains_key("baseW") { - return; - } - let num = |k: &str| d.params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - let sel = selection_xfrm(app); - let x = num("x").or_else(|| sel.map(|b| b.x)).unwrap_or(0.0); - let y = num("y").or_else(|| sel.map(|b| b.y)).unwrap_or(0.0); - let w = num("w").or_else(|| sel.map(|b| b.w)).unwrap_or(72.0); - let h = num("h").or_else(|| sel.map(|b| b.h)).unwrap_or(72.0); - let rot = num("rot").or_else(|| sel.map(|b| b.rot)).unwrap_or(0.0); - let sw = num("scaleW").unwrap_or(100.0); - let sh = num("scaleH").unwrap_or(100.0); - let lock = d.params.get("lockAspect").and_then(Value::as_bool).unwrap_or(false); - put_dim(d, "x", x, num_text(x / 72.0)); - put_dim(d, "y", y, num_text(y / 72.0)); - put_dim(d, "w", w, num_text(w / 72.0)); - put_dim(d, "h", h, num_text(h / 72.0)); - put_dim(d, "rot", rot, num_text(rot)); - put_dim(d, "scaleW", sw, num_text(sw)); - put_dim(d, "scaleH", sh, num_text(sh)); - d.fields.insert("baseW".into(), w.to_string()); - d.fields.insert("baseH".into(), h.to_string()); - let bit = if lock { "1" } else { "0" }; - d.fields.insert("lock".into(), bit.into()); - d.fields.insert("prevlock".into(), bit.into()); -} - -fn scale_of(pt: f64, base: f64) -> f64 { - if base > 0.0 { pt / base * 100.0 } else { 100.0 } -} - -fn current_points(d: &Dialog, key: &str) -> Option { - if edited(d, key) { parse_shown(&field_of(d, key)).map(|inches| inches * 72.0) } else { exact_of(d, key) } -} - -/// Keep scale tied to size, and the other side tied when aspect is locked. -fn reconcile_size_fields(d: &mut Dialog) { - let lock = field_of(d, "lock") == "1"; - let was_locked = field_of(d, "prevlock") == "1"; - let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0); - let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0); - let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 }; - let (w_s, h_s, sw_s, sh_s) = (field_of(d, "w"), field_of(d, "h"), field_of(d, "scaleW"), field_of(d, "scaleH")); - let (prev_w, prev_h, prev_sw, prev_sh) = (field_of(d, "prevw"), field_of(d, "prevh"), field_of(d, "prevscaleW"), field_of(d, "prevscaleH")); - if w_s != prev_w { - if let Some(inches) = parse_shown(&w_s) { - let w_pt = inches * 72.0; - d.fields.insert("wPt".into(), w_pt.to_string()); - let pct = scale_of(w_pt, base_w); - publish_dim(d, "scaleW", pct, num_text(pct)); - if lock { - let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; - publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); - let hp = scale_of(h_pt, base_h); - publish_dim(d, "scaleH", hp, num_text(hp)); - } - } - } else if h_s != prev_h { - if let Some(inches) = parse_shown(&h_s) { - let h_pt = inches * 72.0; - d.fields.insert("hPt".into(), h_pt.to_string()); - let pct = scale_of(h_pt, base_h); - publish_dim(d, "scaleH", pct, num_text(pct)); - if lock { - let w_pt = h_pt * ar; - publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); - let wp = scale_of(w_pt, base_w); - publish_dim(d, "scaleW", wp, num_text(wp)); - } - } - } else if sw_s != prev_sw { - if let Some(pct) = parse_shown(&sw_s) { - let w_pt = base_w * pct / 100.0; - d.fields.insert("scaleWPt".into(), pct.to_string()); - publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); - if lock { - let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; - publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); - publish_dim(d, "scaleH", pct, num_text(pct)); - } - } - } else if sh_s != prev_sh { - if let Some(pct) = parse_shown(&sh_s) { - let h_pt = base_h * pct / 100.0; - d.fields.insert("scaleHPt".into(), pct.to_string()); - publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); - if lock { - let w_pt = h_pt * ar; - publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); - publish_dim(d, "scaleW", pct, num_text(pct)); - } - } - } else if lock - && !was_locked - && let Some(w_pt) = current_points(d, "w") - { - let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; - publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); - let hp = scale_of(h_pt, base_h); - publish_dim(d, "scaleH", hp, num_text(hp)); - let wp = scale_of(w_pt, base_w); - publish_dim(d, "scaleW", wp, num_text(wp)); - } - for key in ["x", "y", "w", "h", "rot", "scaleW", "scaleH"] { - let v = field_of(d, key); - d.fields.insert(format!("prev{key}"), v); - } - d.fields.insert("prevlock".into(), if lock { "1".into() } else { "0".into() }); -} - -fn shown_points(d: &Dialog, key: &str, label: &str) -> Result { - if edited(d, key) { - let n = parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"))?; - return Ok(n * 72.0); - } - exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) -} - -fn shown_number(d: &Dialog, key: &str, label: &str) -> Result { - if edited(d, key) { - return parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number")); - } - exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) -} - -fn size_position_value(d: &Dialog) -> Result { - let x = shown_points(d, "x", "Horizontal")?; - let y = shown_points(d, "y", "Vertical")?; - let rot = shown_number(d, "rot", "Rotation")?; - let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0); - let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0); - let mut w = if edited(d, "w") { - shown_points(d, "w", "Width")? - } else if edited(d, "scaleW") { - base_w * shown_number(d, "scaleW", "Scale width")? / 100.0 - } else { - shown_points(d, "w", "Width")? - }; - let mut h = if edited(d, "h") { - shown_points(d, "h", "Height")? - } else if edited(d, "scaleH") { - base_h * shown_number(d, "scaleH", "Scale height")? / 100.0 - } else { - shown_points(d, "h", "Height")? - }; - if field_of(d, "lock") == "1" { - let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 }; - let width_drives = edited(d, "w") || edited(d, "scaleW") || (!edited(d, "h") && !edited(d, "scaleH")); - if width_drives { - h = if ar > 0.0 { w / ar } else { h }; - } else { - w = h * ar; - } - } - Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"})) -} - /// Draw a labelled single-line text row bound to a dialog key and store the edited value. fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { let mut v = field_of(d, key); @@ -2618,328 +200,46 @@ fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { d.fields.insert(key.into(), v); } -fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - seed_size_position(app, d); - ui.label(egui::RichText::new("Size").strong()); - text_row(ui, d, "Height (in):", "h"); - text_row(ui, d, "Width (in):", "w"); - text_row(ui, d, "Rotation (°):", "rot"); - text_row(ui, d, "Scale height (%):", "scaleH"); - text_row(ui, d, "Scale width (%):", "scaleW"); - check(ui, d, "Lock aspect ratio", "lock", false); - ui.add_space(8.0); - ui.label(egui::RichText::new("Position").strong()); - ui.label("From the top-left corner of the slide"); - text_row(ui, d, "Horizontal (in):", "x"); - text_row(ui, d, "Vertical (in):", "y"); - reconcile_size_fields(d); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - match size_position_value(d) { - Ok(p) => return app.run("shape.sizePosition", p).is_ok(), - Err(msg) => { - app.set_status(msg); - return false; - } - } - } - cancel -} - -const DATE_LANGS: &[(&str, &str)] = &[ - ("en-US", "English (United States)"), - ("en-GB", "English (United Kingdom)"), - ("fr-FR", "French (France)"), - ("ja-JP", "Japanese"), - ("sv-SE", "Swedish"), -]; - use deckcraft_text::datetime::DATE_KINDS; -/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only; -/// other languages keep the numeric date and the 24-hour times. -fn date_rows(lang: &str) -> Vec<(&'static str, String)> { - let now = deckcraft_text::datetime::DateTime::now_local(); - let mut rows = Vec::new(); - for kind in DATE_KINDS { - if let Some(now) = now - && let Some(sample) = deckcraft_text::datetime::field_text(kind, Some(lang), now) - { - rows.push((kind, sample)); - } - } - if rows.is_empty() { - rows.push(("datetime1", "datetime1".into())); - } - rows -} +mod about; +mod alt_text; +mod body; +mod columns; +mod date_time; +mod equation; +mod export; +mod find; +mod font; +mod grid_options; +mod hyperlink; +mod language; +mod paragraph; +mod photo_album; +mod preferences; +mod size_position; +mod slide_size; +mod tabs; +mod theme_colors; +mod theme_fonts; +mod trim; +mod zoom; -/// Render language and format controls; OK applies `insert.dateTime`, and the return value says -/// whether the dialog should close. -fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.set_min_width(440.0); - ui.label("Available formats:"); - let lang = d.get("language", "en-US"); - let rows = date_rows(&lang); - if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1")) - && let Some((k, _)) = rows.first() - { - d.fields.insert("format".into(), (*k).to_string()); - } - let format = d.get("format", "datetime1"); - egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(220.0).auto_shrink([false, false]).show(ui, |ui| { - for (kind, sample) in &rows { - if ui.selectable_label(format == *kind, sample).clicked() { - d.fields.insert("format".into(), (*kind).to_string()); - } - } - }); - let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); - ui.horizontal(|ui| { - ui.label("Language:"); - let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { - for (k, l) in DATE_LANGS { - if ui.selectable_label(lang == *k, *l).clicked() { - d.fields.insert("language".into(), (*k).to_string()); - } - } - }); - let shown = lang_label.to_string(); - opened.response.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Language"); - info.current_text_value = Some(shown.clone()); - info - }); - }); - ui.label( - egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), - ); - let update = check(ui, d, "Update automatically", "update", true); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let lang = d.get("language", "en-US"); - let mut format = d.get("format", "datetime1"); - let probe = deckcraft_text::datetime::DateTime::new(2026, 10, 9, 20, 5, 7); - if deckcraft_text::datetime::field_text(&format, Some(&lang), probe).is_none() { - format = "datetime1".into(); - } - return app.run("insert.dateTime", json!({"format": format, "update": update, "language": lang})).is_ok(); - } - cancel -} - -fn format_spacing(v: f64) -> String { - if !v.is_finite() { - return "6".into(); - } - if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } -} - -/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one -/// `view.gridOptions` call; Cancel leaves the presentation as it was. -fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); - let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); - let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); - let spacing_default = format_spacing(app.session.prefs.grid_spacing); - field(ui, d, "Spacing (pt):", "spacing", &spacing_default); - ui.add_space(6.0); - ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); - let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); - let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); - if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { - ui.add_space(4.0); - ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); - } - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let spacing = num(d, "spacing", app.session.prefs.grid_spacing); - match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { - Ok(_) => return true, - Err(e) => { - d.fields.insert("error".into(), e); - return false; - } - } - } - cancel -} - -/// The Columns dialog's changed fields as `format.columns` params: Number and Spacing (inches, -/// sent in points). A field left as the dialog showed it is left out, so each selected text box -/// keeps its own value; `None` when neither field changed. -fn columns_value(d: &Dialog, shown_count: &str, shown_spacing: &str) -> Result, String> { - let mut p = serde_json::Map::new(); - let count = field_of(d, "count"); - if count != shown_count { - let n = count.trim().parse::().ok().filter(|n| (1..=16).contains(n)).ok_or("Number must be a whole number from 1 to 16")?; - p.insert("count".into(), json!(n)); - } - let spacing = field_of(d, "spacing"); - if spacing != shown_spacing { - let inches = spacing - .trim() - .trim_end_matches("in") - .trim_end_matches('"') - .trim() - .parse::() - .ok() - .filter(|v| v.is_finite() && (0.0..=22.0).contains(v)) - .ok_or("Spacing must be from 0 to 22 inches")?; - p.insert("spacing".into(), json!(inches * 72.0)); - } - Ok((!p.is_empty()).then_some(Value::Object(p))) -} - -/// Columns: the number of columns in the text box and the space between them. OK applies one -/// `format.columns` call with the fields that changed, or none when neither did; Cancel leaves -/// the text box as it was. -fn columns_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let count = d.params.get("count").and_then(Value::as_u64).unwrap_or(1).to_string(); - let spacing = num_text(d.params.get("spacing").and_then(Value::as_f64).unwrap_or(0.0) / 72.0); - field(ui, d, "Number:", "count", &count); - field(ui, d, "Spacing (in):", "spacing", &spacing); - if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { - ui.add_space(4.0); - ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); - } - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let applied = columns_value(d, &count, &spacing).and_then(|p| match p { - Some(p) => app.run("format.columns", p).map(|_| ()), - None => Ok(()), - }); - match applied { - Ok(()) => return true, - Err(e) => { - d.fields.insert("error".into(), e); - return false; - } - } - } - cancel -} - -fn pretty_equation(s: &str) -> String { - let sup = |c: char| match c { - '0' => '⁰', - '1' => '¹', - '2' => '²', - '3' => '³', - '4' => '⁴', - '5' => '⁵', - '6' => '⁶', - '7' => '⁷', - '8' => '⁸', - '9' => '⁹', - 'n' => 'ⁿ', - 'i' => 'ⁱ', - '+' => '⁺', - '-' => '⁻', - _ => c, - }; - let mut out = String::new(); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '^' => { - while let Some(&n) = chars.peek() { - if n.is_ascii_alphanumeric() || n == '+' || n == '-' { - out.push(sup(n)); - chars.next(); - if !n.is_ascii_digit() { - break; - } - } else { - break; - } - } - } - '*' => out.push('·'), - _ => out.push(c), - } - } - out.replace("sqrt", "√").replace("<=", "≤").replace(">=", "≥").replace("!=", "≠").replace("pi", "π").replace("+-", "±") -} - -pub fn about(_app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.vertical_centered(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); - paint_logo(ui.painter(), r); - ui.label(egui::RichText::new("DeckCraft").font(theme::bold(22.0))); - ui.label(egui::RichText::new(version_line()).color(t.text_dim)); - ui.add_space(6.0); - ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust."); - ui.label("An open-source, clean-room project. Not affiliated with Microsoft."); - ui.add_space(6.0); - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); - ui.hyperlink_to("getartcraft.com/apps/deckcraft", deckcraft_engine::links::APP_PAGE); - ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); - ui.add_space(6.0); - ui.label(egui::RichText::new("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors").size(11.0).color(t.text_faint)); - }); -} - -/// "Version X.Y.Z" plus, for release builds, the short commit and build date. The version comes -/// from `[workspace.package] version` (the single source of truth, `cargo xtask version`); the -/// release workflow sets `DECKCRAFT_BUILD_SHA` / `DECKCRAFT_BUILD_DATE` at build time. -pub fn version_line() -> String { - let mut s = format!("Version {}", env!("CARGO_PKG_VERSION")); - let sha = option_env!("DECKCRAFT_BUILD_SHA").map(|s| s.get(..9).unwrap_or(s)).filter(|s| !s.is_empty()); - let date = option_env!("DECKCRAFT_BUILD_DATE").filter(|s| !s.is_empty()); - match (sha, date) { - (Some(sha), Some(date)) => s.push_str(&format!(" ({sha}, {date})")), - (Some(x), None) | (None, Some(x)) => s.push_str(&format!(" ({x})")), - (None, None) => {} - } - s -} - -/// DeckCraft's mark, drawn in code: a slide card with a play triangle in our orange. -pub fn paint_logo(p: &egui::Painter, r: Rect) { - let orange = Color32::from_rgb(0xF2, 0x6B, 0x1D); - let deep = Color32::from_rgb(0xC2, 0x4E, 0x14); - let card = r.shrink(r.width() * 0.08); - p.rect_filled(card.translate(vec2(r.width() * 0.04, r.width() * 0.05)), CornerRadius::same((r.width() * 0.14) as u8), deep); - p.rect_filled(card, CornerRadius::same((r.width() * 0.14) as u8), orange); - let c = card.center(); - let s = card.width() * 0.22; - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x - s * 0.7, c.y - s), pos2(c.x + s, c.y), pos2(c.x - s * 0.7, c.y + s)], - Color32::WHITE, - Stroke::NONE, - )); -} - -/// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at -/// the caret, else the first run of the first selected shape; US English when none says. -pub fn selected_language(app: &SlideApp) -> (String, bool) { - let Some(st) = app.session.active() else { return ("en-US".into(), false) }; - let run = |sh: &deckcraft_model::Shape, at: Option<(usize, usize)>| -> Option { - let t = sh.text.as_ref()?; - if let Some((p, c)) = at { - let para = t.paragraphs.get(p)?; - let mut n = 0; - for r in ¶.runs { - n += r.char_len(); - if c <= n { - return Some(r.props.clone()); - } - } - return para.runs.last().map(|r| r.props.clone()); - } - t.paragraphs.iter().flat_map(|p| &p.runs).next().map(|r| r.props.clone()) - }; - let props = match &st.selection.text { - Some(t) if !t.notes && t.cell.is_none() => st.shape(t.shape).and_then(|sh| run(sh, Some(t.caret))), - _ => st.selection.shapes.first().and_then(|id| st.shape(*id)).and_then(|sh| run(sh, None)), - }; - let props = props.unwrap_or_default(); - (props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true)) -} +#[cfg(test)] +use about::{CREDITS_SIZE, credits_block}; +pub use about::{about, paint_logo, version_line}; +#[cfg(test)] +use alt_text::alt_text_starts_decorative; +use body::body; +#[cfg(test)] +use hyperlink::{ + CurrentLink, HyperlinkSeed, common_click, hyperlink_command, hyperlink_seed_of, hyperlink_should_apply, link_covering, seed_from, seed_of_link, +}; +pub use language::selected_language; +use preferences::restore_preferences; +#[cfg(test)] +use trim::{ms_as_seconds, seconds_text_to_ms, trim_seed}; +pub use zoom::parse_zoom_percent; fn seed_once(d: &mut Dialog, key: &str, val: String) { d.fields.entry(key.to_string()).or_insert(val); @@ -2963,21 +263,6 @@ fn color_label(v: &Value) -> String { } } -fn seed_font_fields(d: &mut Dialog) { - seed_once(d, "family", json_text(d.params.get("family"), "")); - seed_once(d, "size", json_text(d.params.get("size"), "18")); - seed_once(d, "bold", json_text(d.params.get("bold"), "0")); - seed_once(d, "italic", json_text(d.params.get("italic"), "0")); - seed_once(d, "underline", json_text(d.params.get("underline"), "none")); - seed_once(d, "strike", json_text(d.params.get("strike"), "none")); - seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); - seed_once(d, "caps", json_text(d.params.get("caps"), "none")); - seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); - let shown = d.params.get("color").map(color_label).unwrap_or_default(); - seed_once(d, "color", shown.clone()); - seed_once(d, "colorSeed", shown); -} - /// Draw a uniquely identified accessible choice combo box and store the selected key; /// the caller supplies the visible label. fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choices: &[(&str, &str)]) { @@ -3005,152 +290,6 @@ fn finite_pt(text: &str) -> Option { text.trim().trim_end_matches("pt").trim().parse::().ok().filter(|v| v.is_finite()) } -/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. -fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.set_min_width(460.0); - seed_font_fields(d); - let mut fams = crate::ribbon::font_families(app); - let family = d.get("family", ""); - if !family.is_empty() && !fams.iter().any(|f| f == &family) { - fams.insert(0, family.clone()); - } - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); - let combo = egui::ComboBox::from_id_salt("font_dialog_family") - .selected_text(if family.is_empty() { " " } else { family.as_str() }) - .width(300.0) - .show_ui(ui, |ui| { - egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { - for f in &fams { - if ui.selectable_label(family == *f, f).clicked() { - d.fields.insert("family".into(), f.clone()); - } - } - }); - }); - let _ = combo.response.labelled_by(field.id); - }); - ui.horizontal(|ui| { - let _ = labeled_text(ui, d, "Size", "size", 64.0); - ui.add_space(16.0); - check(ui, d, "Bold", "bold", false); - check(ui, d, "Italic", "italic", false); - }); - ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); - choice( - ui, - d, - "font_dialog_underline", - "Underline", - "underline", - &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], - ); - ui.add_space(8.0); - let _ = labeled_text(ui, d, "Color", "color", 110.0); - }); - ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); - choice(ui, d, "font_dialog_strike", "Strikethrough", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); - }); - ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); - choice(ui, d, "font_dialog_baseline", "Baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); - ui.add_space(8.0); - ui.label("Caps"); - choice(ui, d, "font_dialog_caps", "Caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); - }); - ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); - let mut spacing = d.get("spacing", "0"); - let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); - let mut picked = None; - let combo = egui::ComboBox::from_id_salt("font_dialog_spacing") - .selected_text(match spacing.as_str() { - "-3" => "Very tight", - "-1.5" => "Tight", - "0" => "Normal", - "3" => "Loose", - "6" => "Very loose", - other => other, - }) - .width(120.0) - .show_ui(ui, |ui| { - for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { - if ui.selectable_label(spacing == v, l).clicked() { - picked = Some(v.to_string()); - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Spacing")); - let _ = edited; - if let Some(v) = picked { - d.fields.insert("spacing".into(), v); - } else { - d.fields.insert("spacing".into(), spacing); - } - }); - let bold = d.get("bold", "0") == "1"; - let italic = d.get("italic", "0") == "1"; - let underline = d.get("underline", "none"); - let strike = d.get("strike", "none"); - let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; - let mut sample = egui::RichText::new("AaBbCcDd").size(shown); - if bold { - sample = sample.strong(); - } - if italic { - sample = sample.italics(); - } - if underline != "none" { - sample = sample.underline(); - } - if strike != "none" { - sample = sample.strikethrough(); - } - ui.add_space(6.0); - ui.label(sample); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let family = d.get("family", ""); - let family = family.trim(); - if family.is_empty() { - app.set_status("Choose a font"); - return false; - } - let Some(size) = finite_pt(&d.get("size", "")) else { - app.set_status("Font size must be a number"); - return false; - }; - let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { - app.set_status("Character spacing must be a number"); - return false; - }; - let mut params = json!({ - "family": family, - "size": size, - "bold": d.get("bold", "0") == "1", - "italic": d.get("italic", "0") == "1", - "underline": d.get("underline", "none"), - "strike": d.get("strike", "none"), - "baseline": d.get("baseline", "none"), - "caps": d.get("caps", "none"), - "spacing": spacing, - }); - let color = d.get("color", ""); - let seed = d.get("colorSeed", ""); - if color == seed { - if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { - params["color"] = c.clone(); - } - } else if !color.trim().is_empty() { - params["color"] = json!(color.trim()); - } - let _ = app.run("format.fontDialog", params); - } - ok || cancel -} - fn pt_text(v: Option<&Value>, default: f64) -> String { match v.and_then(Value::as_f64).filter(|n| n.is_finite()) { Some(n) => crate::widgets::fmt_num(n, ""), @@ -3162,382 +301,6 @@ fn inches_text(v: Option<&Value>) -> String { crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "") } -fn seed_paragraph_fields(d: &mut Dialog) { - seed_once(d, "align", json_text(d.params.get("align"), "left")); - seed_once(d, "before", inches_text(d.params.get("indentLeft"))); - seed_once(d, "after", inches_text(d.params.get("indentRight"))); - seed_once(d, "special", json_text(d.params.get("special"), "none")); - seed_once(d, "by", inches_text(d.params.get("specialBy"))); - seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); - seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); - seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); - seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); - seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); - seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); -} - -fn paragraph_return_params(d: &mut Dialog) -> Value { - let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; - let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; - let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; - let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); - let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); - let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); - let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); - let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); - json!({ - "align": d.get("align", "left"), - "indentLeft": before, - "indentRight": after, - "special": d.get("special", "none"), - "specialBy": by, - "spaceBefore": space_before, - "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, - "spaceAfter": space_after, - "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, - "lineKind": d.get("lineKind", "multiple"), - "lineValue": line_value, - "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), - "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), - }) -} - -fn line_preset_label(kind: &str, value: &str) -> &'static str { - if kind == "exactly" { - "Exactly" - } else { - match value { - "1" => "Single", - "1.5" => "1.5 lines", - "2" => "Double", - _ => "Multiple", - } - } -} - -/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. -fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.set_min_width(460.0); - seed_paragraph_fields(d); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); - choice( - ui, - d, - "paragraph_align", - "Alignment", - "align", - &[ - ("left", "Left"), - ("center", "Center"), - ("right", "Right"), - ("justify", "Justify"), - ("justLow", "Justify low"), - ("distributed", "Distributed"), - ], - ); - }); - ui.label(egui::RichText::new("Indentation").strong()); - ui.horizontal(|ui| { - let _ = labeled_text(ui, d, "Before text", "before", 64.0); - ui.label("in"); - ui.add_space(12.0); - let _ = labeled_text(ui, d, "After text", "after", 64.0); - ui.label("in"); - }); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); - choice(ui, d, "paragraph_special", "Special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); - ui.add_space(8.0); - let _ = labeled_text(ui, d, "By", "by", 64.0); - ui.label("in"); - }); - ui.label(egui::RichText::new("Spacing").strong()); - ui.horizontal(|ui| { - let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); - ui.label("pt"); - ui.add_space(12.0); - let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); - ui.label("pt"); - }); - let mut picked: Option<(&'static str, Option<&'static str>)> = None; - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); - let kind = d.get("lineKind", "multiple"); - let value = d.get("lineValue", "1"); - let combo = egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { - for (label, k, v) in [ - ("Single", "multiple", Some("1")), - ("1.5 lines", "multiple", Some("1.5")), - ("Double", "multiple", Some("2")), - ("Multiple", "multiple", None), - ("Exactly", "exactly", None), - ] { - if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { - picked = Some((k, v)); - } - } - }); - let _ = combo.response.labelled_by(field.id); - ui.add_space(8.0); - let _ = labeled_text(ui, d, "At", "lineValue", 64.0); - }); - if let Some((kind, value)) = picked { - d.fields.insert("lineKind".into(), kind.into()); - if let Some(v) = value { - d.fields.insert("lineValue".into(), v.into()); - } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { - d.fields.insert("lineValue".into(), "18".into()); - } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { - d.fields.insert("lineValue".into(), "1".into()); - } - } - let mut open_tabs = false; - ui.horizontal(|ui| { - if ui.button("Tabs…").clicked() { - open_tabs = true; - } - }); - let (ok, cancel) = buttons(ui, "OK"); - if open_tabs { - let mut tabs = Dialog::new("tabs"); - tabs.params = json!({ - "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), - "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), - "returnId": "paragraph", - "returnParams": paragraph_return_params(d), - }); - app.dialog = Some(tabs); - return true; - } - if ok { - let Some(before_in) = finite_pt(&d.get("before", "")) else { - app.set_status("Indent before text must be a number of inches"); - return false; - }; - let Some(after_in) = finite_pt(&d.get("after", "")) else { - app.set_status("Indent after text must be a number of inches"); - return false; - }; - let Some(by_in) = finite_pt(&d.get("by", "")) else { - app.set_status("Special indent must be a number of inches"); - return false; - }; - let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { - app.set_status("Spacing before must be a number"); - return false; - }; - let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { - app.set_status("Spacing after must be a number"); - return false; - }; - let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { - app.set_status("Line spacing must be a number"); - return false; - }; - let mut params = json!({ - "align": d.get("align", "left"), - "indentLeft": before_in * 72.0, - "indentRight": after_in * 72.0, - "special": d.get("special", "none"), - "specialBy": by_in * 72.0, - "spaceBefore": space_before, - "spaceAfter": space_after, - "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, - "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, - }); - if d.get("lineKind", "multiple") == "exactly" { - params["lineSpacingPt"] = json!(line_value); - } else { - params["lineSpacing"] = json!(line_value); - } - if app.run("format.paragraphDialog", params).is_err() { - return false; - } - } - ok || cancel -} - -const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; - -/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a -/// list (inches, as PowerPoint's dialog) and applied together with OK. -fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - if !d.fields.contains_key("stops") { - // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the - // dialog; otherwise they are read from the caret's paragraph. - let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; - let stops: Vec = r["tabs"] - .as_array() - .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) - .unwrap_or_default(); - d.fields.insert("stops".into(), stops.join("|")); - d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); - } - let mut stops: Vec<(f64, String)> = d - .get("stops", "") - .split('|') - .filter_map(|x| { - let (p, a) = x.split_once(':')?; - Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) - }) - .collect(); - field(ui, d, "Default tab stops (in):", "default", "1"); - ui.add_space(6.0); - ui.label("Tab stops:"); - let mut remove = None; - egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { - if stops.is_empty() { - ui.label("(none)"); - ui.end_row(); - } - for (i, (pos, align)) in stops.iter().enumerate() { - ui.label(format!("{pos:.2}\"")); - ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); - if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { - remove = Some(i); - } - ui.end_row(); - } - }); - if let Some(i) = remove { - stops.remove(i); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - let l = ui.label("Tab stop position (in):"); - let mut v = d.get("newpos", ""); - ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id); - d.fields.insert("newpos".into(), v); - }); - let cur = d.get("newalign", "l"); - ui.horizontal(|ui| { - ui.label("Alignment:"); - for (k, label) in TAB_KINDS { - if ui.radio(cur == k, label).clicked() { - d.fields.insert("newalign".into(), k.into()); - } - } - }); - ui.horizontal(|ui| { - if ui.button("Set").clicked() - && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) - { - stops.retain(|(q, _)| (q - p).abs() > 0.005); - stops.push((p, d.get("newalign", "l"))); - stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - d.fields.insert("newpos".into(), String::new()); - } - if ui.button("Clear All").clicked() { - stops.clear(); - } - }); - d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); - let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; - if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { - app.set_status(e); - } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { - // Back in Paragraph, its Tabs… shows what was just applied. - ret.insert("tabs".into(), json!(set)); - ret.insert("defaultTab".into(), json!(dt)); - } - } - ok || cancel -} - -/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. -fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let replace_mode = d.get("replace", "0") == "1"; - // "Find what:" has the keyboard when the dialog opens, so Ctrl+F and typing finds (#951). - let mut what = d.get("text", ""); - ui.horizontal(|ui| { - ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Find what:")); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(180.0)); - // Enter finds the next match and leaves the keyboard here, so the search can be changed. - if !d.fields.contains_key("focused") || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { - r.request_focus(); - d.fields.insert("focused".into(), "1".into()); - } - }); - d.fields.insert("text".into(), what); - if replace_mode { - field(ui, d, "Replace with:", "with", ""); - } - let case = check(ui, d, "Match case", "matchCase", false); - let whole = check(ui, d, "Find whole words only", "wholeWords", false); - let text = d.get("text", ""); - let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); - let mut close = false; - let mut find_next = false; - ui.add_space(6.0); - ui.horizontal(|ui| { - let has = !text.is_empty(); - if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { - find_next = true; - } - if replace_mode { - if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { - let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); - if let Some(at) = cur { - let mut p = q.clone(); - p["with"] = json!(d.get("with", "")); - p["at"] = at; - if app.session.execute("text.replace", &p).is_ok() { - let n = d.get("next", "1").parse::().unwrap_or(1); - d.fields.insert("next".into(), n.saturating_sub(1).to_string()); - } - } - find_next = true; - } - if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { - let mut p = q.clone(); - p["with"] = json!(d.get("with", "")); - let msg = match app.session.execute("text.replace", &p) { - Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), - Err(_) => format!("DeckCraft can't find “{text}”."), - }; - d.fields.insert("status".into(), msg); - d.fields.remove("cur"); - } - } else if ui.button("Replace…").clicked() { - d.fields.insert("replace".into(), "1".into()); - } - if ui.button("Close").clicked() { - close = true; - } - }); - if find_next { - let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); - if list.is_empty() { - d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); - d.fields.remove("cur"); - } else { - let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); - let m = &list[n]; - let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); - if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { - let _ = app.run("edit.select", json!({"ids": [id]})); - } - d.fields.insert("cur".into(), m.to_string()); - d.fields.insert("next".into(), (n + 1).to_string()); - let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; - d.fields.insert( - "status".into(), - format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), - ); - } - } - let status = d.get("status", ""); - if !status.is_empty() { - ui.add_space(4.0); - ui.label(status); - } - close -} - pub mod credits; #[cfg(test)] mod font_dialog_tests; @@ -3553,65 +316,6 @@ mod columns_dialog_tests; #[cfg(test)] mod photo_album_tests; -/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden -/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. -fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result { - use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS}; - let n = app.session.active().map_or(0, |s| s.doc.slides.len()); - let size = d.get("gifSize", "medium"); - let size_label = |k: &str| match k { - "small" => "Small (640 px wide)", - "large" => "Large (1280 px wide)", - "extraLarge" => "Extra Large (1920 px wide)", - "custom" => "Custom width", - _ => "Medium (960 px wide)", - }; - ui.add_space(4.0); - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); - let combo = egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { - for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { - if ui.selectable_label(size == k, size_label(k)).clicked() { - d.fields.insert("gifSize".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - if size == "custom" { - field(ui, d, "Width (pixels):", "gifWidth", "960"); - } - field(ui, d, "Seconds per slide:", "gifSeconds", "5"); - let n_text = n.max(1).to_string(); - field(ui, d, "From slide:", "gifFrom", "1"); - field(ui, d, "To slide:", "gifTo", &n_text); - let forever = check(ui, d, "Loop forever", "gifLoop", true); - let hidden = check(ui, d, "Include hidden slides", "gifHidden", false); - - let width = if size == "custom" { - let w = num(d, "gifWidth", 960.0).round(); - if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) { - return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels")); - } - json!(w as u32) - } else { - json!(size) - }; - let seconds = num(d, "gifSeconds", f64::NAN); - if deckcraft_engine::gif_export::check_seconds(seconds).is_err() { - return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}")); - } - let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden}); - let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round()); - if !(from >= 1.0 && from <= to && to <= n as f64) { - return Err(format!("Slides: choose a range from 1 to {n}")); - } - if from > 1.0 || to < n as f64 { - p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::>()); - } - Ok(p) -} - #[cfg(test)] mod theme_colors_tests; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 000000000..00f4433ca --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -0,0 +1,66 @@ +use super::*; + +/// Room the About credits need for their table; the About tab keeps its compact size. +pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0); + +/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the +/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first +/// credits row together, so the text was garbled and the tabs stopped answering (#843). +pub(super) fn credits_block(ui: &mut Ui, models: bool) -> Rect { + ui.allocate_ui(CREDITS_SIZE, |ui| { + ui.set_min_size(CREDITS_SIZE); + if models { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) } + }) + .response + .rect +} + +pub fn about(_app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.vertical_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); + paint_logo(ui.painter(), r); + ui.label(egui::RichText::new("DeckCraft").font(theme::bold(22.0))); + ui.label(egui::RichText::new(version_line()).color(t.text_dim)); + ui.add_space(6.0); + ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust."); + ui.label("An open-source, clean-room project. Not affiliated with Microsoft."); + ui.add_space(6.0); + ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to("getartcraft.com/apps/deckcraft", deckcraft_engine::links::APP_PAGE); + ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); + ui.add_space(6.0); + ui.label(egui::RichText::new("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors").size(11.0).color(t.text_faint)); + }); +} + +/// "Version X.Y.Z" plus, for release builds, the short commit and build date. The version comes +/// from `[workspace.package] version` (the single source of truth, `cargo xtask version`); the +/// release workflow sets `DECKCRAFT_BUILD_SHA` / `DECKCRAFT_BUILD_DATE` at build time. +pub fn version_line() -> String { + let mut s = format!("Version {}", env!("CARGO_PKG_VERSION")); + let sha = option_env!("DECKCRAFT_BUILD_SHA").map(|s| s.get(..9).unwrap_or(s)).filter(|s| !s.is_empty()); + let date = option_env!("DECKCRAFT_BUILD_DATE").filter(|s| !s.is_empty()); + match (sha, date) { + (Some(sha), Some(date)) => s.push_str(&format!(" ({sha}, {date})")), + (Some(x), None) | (None, Some(x)) => s.push_str(&format!(" ({x})")), + (None, None) => {} + } + s +} + +/// DeckCraft's mark, drawn in code: a slide card with a play triangle in our orange. +pub fn paint_logo(p: &egui::Painter, r: Rect) { + let orange = Color32::from_rgb(0xF2, 0x6B, 0x1D); + let deep = Color32::from_rgb(0xC2, 0x4E, 0x14); + let card = r.shrink(r.width() * 0.08); + p.rect_filled(card.translate(vec2(r.width() * 0.04, r.width() * 0.05)), CornerRadius::same((r.width() * 0.14) as u8), deep); + p.rect_filled(card, CornerRadius::same((r.width() * 0.14) as u8), orange); + let c = card.center(); + let s = card.width() * 0.22; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x - s * 0.7, c.y - s), pos2(c.x + s, c.y), pos2(c.x - s * 0.7, c.y + s)], + Color32::WHITE, + Stroke::NONE, + )); +} diff --git a/crates/ui-egui/src/dialogs/alt_text.rs b/crates/ui-egui/src/dialogs/alt_text.rs new file mode 100644 index 000000000..303b676c5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/alt_text.rs @@ -0,0 +1,11 @@ +/// "Mark as decorative" starts checked when every selected shape is already decorative. +pub(super) fn alt_text_starts_decorative(decorative: impl IntoIterator) -> bool { + let mut any = false; + for on in decorative { + if !on { + return false; + } + any = true; + } + any +} diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs new file mode 100644 index 000000000..071e042a6 --- /dev/null +++ b/crates/ui-egui/src/dialogs/body.rs @@ -0,0 +1,1345 @@ +use super::about::{about, credits_block}; +use super::alt_text::alt_text_starts_decorative; +use super::columns::columns_dialog; +use super::date_time::{date_rows, date_time_dialog}; +use super::equation::pretty_equation; +use super::export::gif_options; +use super::find::find_dialog; +use super::font::font_dialog; +use super::grid_options::grid_options; +use super::hyperlink::{hyperlink_command, hyperlink_seed_of, hyperlink_should_apply}; +use super::language::selected_language; +use super::paragraph::paragraph_dialog; +use super::photo_album::photo_album_dialog; +use super::preferences::{preferences_snapshot, restore_preferences}; +use super::size_position::size_position_dialog; +use super::slide_size::{put_slide_dim, slide_dim_points, unit_abbr}; +use super::tabs::tabs_dialog; +use super::theme_colors::theme_colors_dialog; +use super::theme_fonts::theme_fonts_dialog; +use super::trim::{seconds_text_to_ms, trim_seed}; +use super::zoom::zoom_dialog; +use super::*; + +/// Returns true when the dialog should close. +pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let run = |app: &mut SlideApp, id: &str, p: Value| { + let _ = app.run(id, p); + }; + match d.id.as_str() { + "table" => { + field(ui, d, "Number of columns:", "cols", "5"); + field(ui, d, "Number of rows:", "rows", "2"); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); + run(app, "insert.table", json!({"rows": r, "cols": c})); + } + ok || cancel + } + "slideSize" => { + let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + let len = app.session.prefs.units; + let ppu = len.points_per_unit(); + // Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so + // a preset, Portrait or an untouched field is not nudged off by the rounding (13.33", + // 33.87 cm read back would make the slide 959.76 or 960.09 pt wide). + if !d.fields.contains_key("wPt") { + put_slide_dim(d, "w", cur.width, ppu); + put_slide_dim(d, "h", cur.height, ppu); + } + let label = ui.label("Slides sized for:"); + let preset = d.get("preset", ""); + let combo = egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { + for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { + if ui.selectable_label(preset == *label, *label).clicked() { + d.fields.insert("preset".into(), label.to_string()); + put_slide_dim(d, "w", *w, ppu); + put_slide_dim(d, "h", *h, ppu); + } + } + }); + let _ = combo.response.labelled_by(label.id); + let abbr = unit_abbr(len); + let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); + field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); + field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); + let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); + ui.horizontal(|ui| { + ui.label("Orientation:"); + let portrait = ui.radio(h > w, "Portrait").clicked(); + let landscape = ui.radio(w >= h, "Landscape").clicked(); + // Picking the other orientation swaps the width and height fields. + if portrait && w > h || landscape && h > w { + put_slide_dim(d, "w", h, ppu); + put_slide_dim(d, "h", w, ppu); + } + }); + let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); + run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); + } + ok || cancel + } + "sizePosition" => size_position_dialog(app, ui, d), + "headerFooter" => { + let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); + let date = check(ui, d, "Date and time", "date", hf.date); + field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); + // The format applies to an automatic date; a fixed one is the text as typed. + let automatic = d.get("dateText", &hf.date_text).is_empty(); + let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; + // Not `d.get`: that stores the default as if the user had picked it. + let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone()); + let rows = date_rows("en-US"); + let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); + ui.add_enabled_ui(date && automatic, |ui| { + ui.horizontal(|ui| { + ui.label(" Format:"); + egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(fmt == *kind, sample).clicked() { + d.fields.insert("dateFormat".into(), (*kind).to_string()); + } + } + }); + }); + }); + let num_on = check(ui, d, "Slide number", "num", hf.slide_number); + let foot = check(ui, d, "Footer", "footer", hf.footer); + field(ui, d, " Footer text:", "footerText", &hf.footer_text); + let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); + ui.add_space(6.0); + let mut apply_all = false; + let mut apply = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + apply_all = ui.button("Apply to All").clicked(); + apply = ui.button("Apply").clicked(); + if ui.button("Cancel").clicked() { + d.fields.insert("cancel".into(), "1".into()); + } + }); + if apply || apply_all { + let dt = d.get("dateText", ""); + let ft = d.get("footerText", ""); + let mut params = serde_json::Map::new(); + params.insert("date".into(), json!(date)); + params.insert("dateText".into(), json!(dt)); + // Only a format the user picked: sending the default would rewrite every slide's + // existing date field (an imported datetime4, say) to datetime1. + if d.fields.contains_key("dateFormat") { + params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); + } + params.insert("slideNumber".into(), json!(num_on)); + params.insert("footer".into(), json!(foot)); + params.insert("footerText".into(), json!(ft)); + params.insert("hideOnTitle".into(), json!(hide)); + params.insert("all".into(), json!(apply_all)); + run(app, "design.headerFooter", serde_json::Value::Object(params)); + } + apply || apply_all || d.fields.contains_key("cancel") + } + "hyperlink" => { + let seed = hyperlink_seed_of(app.session.active()); + let slide_default = seed.slide.to_string(); + let mode = d.get("mode", seed.mode); + ui.horizontal(|ui| { + if ui.selectable_label(mode == "url", "Web Page or File").clicked() { + d.fields.insert("mode".into(), "url".into()); + } + if ui.selectable_label(mode == "slide", "This Document").clicked() { + d.fields.insert("mode".into(), "slide".into()); + } + if ui.selectable_label(mode == "email", "Email Address").clicked() { + d.fields.insert("mode".into(), "email".into()); + } + }); + match mode.as_str() { + "slide" => { + let titles: Vec = app + .session + .active() + .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) + .unwrap_or_default(); + let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for (i, t) in titles.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("slide".into(), i.to_string()); + } + } + }); + } + "email" => field(ui, d, "Email address:", "email", &seed.email), + _ => field(ui, d, "Address:", "url", &seed.url), + } + field(ui, d, "ScreenTip:", "tip", &seed.tip); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let mode_now = d.get("mode", seed.mode); + let url = d.get("url", &seed.url); + let email = d.get("email", &seed.email); + let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); + let tip = d.get("tip", &seed.tip); + if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { + run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); + } + } + ok || cancel + } + "setupShow" => { + let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); + let ty = d.get("type", &sh.show_type); + ui.label("Show type"); + for (l, v) in [ + ("Presented by a speaker (full screen)", "speaker"), + ("Browsed by an individual (window)", "browsed"), + ("Browsed at a kiosk (full screen)", "kiosk"), + ] { + if ui.radio(ty == v, l).clicked() { + d.fields.insert("type".into(), v.into()); + } + } + let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); + let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); + let na = check(ui, d, "Show without animation", "na", sh.without_animation); + let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); + let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); + let field = ui.label("Slides"); + let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + let combo = egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { + d.fields.insert("custom".into(), String::new()); + } + for n in &names { + if ui.selectable_label(&chosen == n, n).clicked() { + d.fields.insert("custom".into(), n.clone()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let ty = d.get("type", "speaker"); + let custom = d.fields.get("custom").cloned().unwrap_or_default(); + run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); + } + ok || cancel + } + "quit" => { + let names: Vec = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect(); + if names.is_empty() { + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + return true; + } + let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) }; + ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); + ui.label("Your changes will be lost if you don't save them."); + ui.add_space(8.0); + let mut done = false; + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + let _ = app.session.execute("file.recovery.discard", &json!({})); + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + done = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if app.save_all() { + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + done = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + done = true; + } + }); + }); + done + } + "close" => { + // File › Close and Ctrl+W on a presentation with unsaved changes. + let Some(name) = app.session.active().map(|d| d.title()) else { return true }; + ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong()); + ui.label("Your changes will be lost if you don't save them."); + ui.add_space(8.0); + let mut done = false; + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + run(app, "file.close", json!({})); + done = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + app.save(); + // In the browser a save is a download: the presentation stays "unsaved". + let web = app.services.pick_save.is_none() && app.services.download.is_some(); + if web || app.session.active().is_some_and(|d| !d.is_dirty()) { + run(app, "file.close", json!({})); + } + done = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + done = true; + } + }); + }); + done + } + "zoom" => zoom_dialog(app, ui, d), + "comment" => { + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Post"); + if ok && !v.trim().is_empty() { + run(app, "comment.add", json!({"text": v})); + app.ui.pane = Some("comments".into()); + } + ok || cancel + } + "bullets" => { + // The current paragraph's list, as the dialog's starting point. + let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); + let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); + let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); + ui.horizontal(|ui| { + for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + let style_key = if tab == "numbered" { "scheme" } else { "char" }; + let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" }); + let styles: &[(&str, &str)] = if tab == "numbered" { + &[ + ("arabicPeriod", "1. 2. 3."), + ("arabicParenR", "1) 2) 3)"), + ("romanUcPeriod", "I. II. III."), + ("romanLcPeriod", "i. ii. iii."), + ("alphaUcPeriod", "A. B. C."), + ("alphaLcParenR", "a) b) c)"), + ("alphaLcPeriod", "a. b. c."), + ] + } else { + &[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")] + }; + egui::Grid::new("bullet-styles").show(ui, |ui| { + for (i, (k, l)) in styles.iter().enumerate() { + let r = ui.add_sized( + vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), + egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), + ); + if r.clicked() { + d.fields.insert(style_key.into(), (*k).into()); + } + if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { + ui.end_row(); + } + } + }); + if tab == "bulleted" { + field(ui, d, "Custom character:", "custom", ""); + } else { + field(ui, d, "Start at:", "start", "1"); + } + field(ui, d, "Size (% of text):", "size", "100"); + let colors = [ + ("", "Automatic"), + ("tx1", "Text"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), + ]; + let color = d.get("color", ""); + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + let combo = egui::ComboBox::from_id_salt("bullet-color") + .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) + .show_ui(ui, |ui| { + for (k, l) in colors { + if ui.selectable_label(color == k, l).clicked() { + d.fields.insert("color".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + ui.horizontal(|ui| { + if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + d.fields.insert("none".into(), "1".into()); + } + }); + let (ok, cancel) = buttons(ui, "OK"); + let none = d.fields.contains_key("none"); + if none { + run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); + } else if ok { + let mut p = json!({"on": true}); + if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { + p["size"] = json!(size.clamp(25.0, 400.0)); + } + if !color.is_empty() { + p["color"] = json!(color); + } + if tab == "numbered" { + p["scheme"] = json!(d.get("scheme", "arabicPeriod")); + p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); + run(app, "format.numbering", p); + } else { + let custom = d.get("custom", ""); + let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•")); + p["char"] = json!(ch); + run(app, "format.bullets", p); + } + } + ok || cancel || none + } + "action" => { + use deckcraft_model::text::Action; + let tab = d.get("tab", "click"); + // Start from the selected shape's current setting for this tab. + let (cur, slides, shows) = app + .session + .active() + .map(|st| { + let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); + let slides: Vec<(deckcraft_model::SlideId, String)> = + st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); + let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); + (sh, slides, shows) + }) + .unwrap_or_default(); + let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { + None => ("none", String::new()), + Some(Action::NextSlide) => ("next", String::new()), + Some(Action::PreviousSlide) => ("previous", String::new()), + Some(Action::FirstSlide) => ("first", String::new()), + Some(Action::LastSlide) => ("last", String::new()), + Some(Action::LastViewed) => ("lastViewed", String::new()), + Some(Action::EndShow) => ("endShow", String::new()), + Some(Action::PlayMedia) => ("playMedia", String::new()), + Some(Action::Url { url }) => ("url", url.clone()), + Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), + Some(Action::Program { path }) => ("program", path.clone()), + Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), + }; + ui.horizontal(|ui| { + for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { + if ui.selectable_label(tab == k, l).clicked() && tab != k { + d.fields.clear(); + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); + let kind = d.get("kind", cur_kind); + let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); + let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { + if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + }; + pick(d, ui, "none", "None"); + ui.horizontal(|ui| { + let field = ui.radio(hyperlink, "Hyperlink to:"); + if field.clicked() && !hyperlink { + d.fields.insert("kind".into(), "next".into()); + } + let targets = [ + ("next", "Next Slide"), + ("previous", "Previous Slide"), + ("first", "First Slide"), + ("last", "Last Slide"), + ("lastViewed", "Last Slide Viewed"), + ("endShow", "End Show"), + ("customShow", "Custom Show…"), + ("slide", "Slide…"), + ("url", "URL…"), + ]; + let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); + ui.add_enabled_ui(hyperlink, |ui| { + let combo = egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { + for (k, l) in targets { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + }); + let kind = d.get("kind", cur_kind); + match kind.as_str() { + "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), + "slide" => { + let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, (_, t)) in slides.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("arg".into(), i.to_string()); + } + } + }); + } + "customShow" => { + if shows.is_empty() { + ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); + } + let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); + for n in &shows { + if ui.selectable_label(&sel == n, n).clicked() { + d.fields.insert("arg".into(), n.clone()); + } + } + } + _ => {} + } + pick(d, ui, "program", "Run program:"); + if d.get("kind", cur_kind) == "program" { + field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); + } + pick(d, ui, "playMedia", "Play the shape's media"); + let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let kind = d.get("kind", cur_kind); + let arg = d.get("arg", &cur_arg); + let mut p = json!({"on": tab, "action": kind, "highlight": hl}); + match kind.as_str() { + "url" => p["url"] = json!(arg), + "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), + "customShow" => p["show"] = json!(arg), + "program" => p["path"] = json!(arg), + _ => {} + } + run(app, "shape.action", p); + } + ok || cancel + } + "altText" => { + let (cur, deco_now) = app + .session + .active() + .map(|s| { + let shapes = s.selected_shapes(); + let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); + let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); + (cur, deco) + }) + .unwrap_or_default(); + ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); + let mut v = d.get("text", &cur); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); + d.fields.insert("text".into(), v.clone()); + let deco = check(ui, d, "Mark as decorative", "deco", deco_now); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "shape.altText", json!({"text": v, "decorative": deco})); + } + ok || cancel + } + "outline" => { + ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "slide.fromOutline", json!({"text": v})); + } + ok || cancel + } + "reuseSlides" => { + let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + let slides: Vec<(usize, String)> = d + .params + .get("slides") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| { + Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) + }) + .collect() + }) + .unwrap_or_default(); + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + ui.label(format!("Insert slides from {name} after the current slide:")); + // Unticked slides are remembered by index; everything starts ticked. + let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + for (i, _) in &slides { + d.fields.remove(&format!("off{i}")); + } + } + if ui.small_button("Select None").clicked() { + for (i, _) in &slides { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + }); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, title) in &slides { + let mut on = !off(d, *i); + let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; + if ui.checkbox(&mut on, label).changed() { + if on { + d.fields.remove(&format!("off{i}")); + } else { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + } + }); + let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut keep, "Keep source formatting").changed() + && let Some(o) = d.params.as_object_mut() + { + o.insert("keep".into(), json!(keep)); + } + let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + if picked.is_empty() { + app.set_status("Tick at least one slide to insert".to_string()); + return false; + } + // A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar. + let source = d.params.get("source").cloned().unwrap_or_default(); + return app.reuse_slides(&source, keep, &picked); + } + cancel + } + "renameSection" | "renameShape" | "renameLayout" => { + let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + field(ui, d, "Name:", "name", &cur); + let (ok, cancel) = buttons(ui, "Rename"); + if ok { + let name = d.get("name", ""); + match d.id.as_str() { + "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), + "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), + _ => run(app, "master.renameLayout", json!({"name": name})), + } + } + ok || cancel + } + "font" => font_dialog(app, ui, d), + "paragraph" => paragraph_dialog(app, ui, d), + "tabs" => tabs_dialog(app, ui, d), + "export" => { + ui.label("Export the presentation as:"); + let fmt = d.get("fmt", "pdf"); + for (l, f) in [ + ("PDF document (.pdf)", "pdf"), + ("PNG images (current slide)", "png"), + ("PNG images (all slides)", "pngAll"), + ("JPEG image (current slide)", "jpeg"), + ("PowerPoint Presentation (.pptx)", "pptx"), + ("Outline (.txt)", "outline"), + ("Animated GIF (.gif)", "gif"), + ("Outline/RTF (.rtf)", "rtf"), + ] { + if ui.radio(fmt == f, l).clicked() { + d.fields.insert("fmt".into(), f.into()); + } + } + let pdf_layout = d.get("pdfLayout", "slides"); + if fmt == "pdf" { + ui.add_space(4.0); + ui.horizontal(|ui| { + let field = ui.label("Print layout:"); + let combo = egui::ComboBox::from_id_salt("pdf_layout") + .selected_text(match pdf_layout.as_str() { + "notes" => "Notes Pages", + "h1" => "Handouts (1 slide per page)", + "h2" => "Handouts (2 slides per page)", + "h3" => "Handouts (3 slides per page)", + "h4" => "Handouts (4 slides per page)", + "h6" => "Handouts (6 slides per page)", + "h9" => "Handouts (9 slides per page)", + _ => "Full Page Slides", + }) + .show_ui(ui, |ui| { + for (k, l) in [ + ("slides", "Full Page Slides"), + ("notes", "Notes Pages"), + ("h1", "Handouts (1 slide per page)"), + ("h2", "Handouts (2 slides per page)"), + ("h3", "Handouts (3 slides per page)"), + ("h4", "Handouts (4 slides per page)"), + ("h6", "Handouts (6 slides per page)"), + ("h9", "Handouts (9 slides per page)"), + ] { + if ui.selectable_label(pdf_layout == k, l).clicked() { + d.fields.insert("pdfLayout".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + let mut hidden = d.get("pdfHidden", "false") == "true"; + if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + d.fields.insert("pdfHidden".into(), hidden.to_string()); + } + } + let png = matches!(fmt.as_str(), "png" | "pngAll"); + if png { + ui.add_space(4.0); + let mut clear = d.get("pngTransparent", "false") == "true"; + if ui + .checkbox(&mut clear, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn") + .changed() + { + d.fields.insert("pngTransparent".into(), clear.to_string()); + } + } + let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; + if let Err(e) = &gif_params { + ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); + } + let pdf_params = { + let (layout, per) = match pdf_layout.as_str() { + "notes" => ("notes", 0), + l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), + _ => ("slides", 0), + }; + json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) + }; + let (ok, cancel) = buttons(ui, "Export"); + if ok { + let gif_params = match gif_params { + Ok(v) => v, + // Keep the dialog open so the range or width can be corrected. + Err(e) => { + app.set_status(e); + return false; + } + }; + let extra = match fmt.as_str() { + "pdf" => pdf_params, + "gif" => gif_params, + _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), + _ => json!({}), + }; + app.export_as(&fmt, extra); + } + ok || cancel + } + "replaceFonts" => { + let used: Vec = + app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); + let mut to = d.get("to", ""); + egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + let field = ui.label("Replace:"); + let combo = egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { + for f in &used { + ui.selectable_value(&mut from, f.clone(), f); + } + }); + let _ = combo.response.labelled_by(field.id); + ui.end_row(); + ui.label("With:"); + ui.vertical(|ui| { + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + let needle = to.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { + for f in + crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) + { + if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { + to = f.clone(); + } + } + }); + }); + ui.end_row(); + }); + if let Some(msg) = d.fields.get("result") { + ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to.clone()); + let (ok, cancel) = buttons(ui, "Replace"); + if ok && !from.is_empty() && !to.trim().is_empty() { + let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); + let msg = + if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; + d.fields.insert("result".into(), msg); + d.fields.remove("from"); + // Stay open, like PowerPoint, so several fonts can be replaced in a row. + return cancel; + } + cancel + } + "symbol" => { + ui.label("Click a symbol to insert it at the insertion point."); + let groups: [(&str, &str); 6] = [ + ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), + ("Currency", "$€£¥¢₹₩₽₺₿"), + ("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"), + ("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"), + ("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"), + ("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"), + ]; + for (g, chars) in groups { + ui.label(egui::RichText::new(g).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for c in chars.chars() { + if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { + if app.session.active().is_some_and(|s| s.selection.text.is_none()) { + let _ = app.run("text.edit", json!({})); + } + let _ = app.run("insert.symbol", json!({"text": c.to_string()})); + } + } + }); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "equation" => { + ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); + let mut v = d.get("eq", ""); + ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); + d.fields.insert("eq".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok && !v.is_empty() { + let pretty = pretty_equation(&v); + let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); + run(app, "text.exit", json!({})); + run(app, "format.font", json!({"family": "Liberation Serif"})); + run(app, "format.italic", json!({"on": true})); + run(app, "format.size", json!({"size": 28})); + } + ok || cancel + } + "pasteSpecial" => { + const OPTS: [(&str, &str); 4] = [ + ("destinationTheme", "Use Destination Theme"), + ("keepSource", "Keep Source Formatting"), + ("picture", "Picture (PNG)"), + ("text", "Keep Text Only"), + ]; + let has_shapes = !app.session.clipboard.shapes.is_empty(); + let mut cur = d.get("as", "destinationTheme"); + ui.label("As:"); + for (id, label) in OPTS { + let ok = has_shapes || matches!(id, "destinationTheme" | "text"); + ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); + } + d.fields.insert("as".into(), cur.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "edit.pasteSpecial", json!({"as": cur})); + } + ok || cancel + } + "find" => find_dialog(app, ui, d), + "spelling" => { + // Words skipped with Ignore All while the dialog is open, one per line. + let ignored = d.get("ignore", ""); + let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); + let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); + let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); + match info.get("found").and_then(Value::as_str) { + Some(f) if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) + } + _ if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) + } + _ => ui.label( + egui::RichText::new(format!( + "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", + info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), + info.get("folders") + .and_then(Value::as_array) + .and_then(|a| a.get(1).or(a.first())) + .and_then(Value::as_str) + .unwrap_or("a dictionaries folder") + )) + .small() + .weak(), + ), + }; + if list.is_empty() { + ui.label("The spelling check is complete. No issues found."); + } + let mut add_ignore = None; + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for item in &list { + let w = item.get("word").and_then(Value::as_str).unwrap_or(""); + let slide = item.get("slide").cloned().unwrap_or_default(); + let shape = item.get("shape").cloned().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("edit.select", json!({"ids": [shape]})); + } + if ui.small_button("Ignore All").clicked() { + add_ignore = Some(w.to_string()); + } + if ui.small_button("Add to Dictionary").clicked() + && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) + { + app.set_status(e.to_string()); + } + }); + let sug: Vec = item + .get("suggestions") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) + { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Repeated word").weak()); + if ui.small_button("Delete Repeated Word").clicked() { + let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; + let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; + let text = app.session.active().and_then(|st| { + let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; + let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { + (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { + tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() + } + _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), + }; + Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) + }); + if let Some(gap) = text { + let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); + if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + }); + } + if !sug.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Change to:").weak()); + for to in &sug { + let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + if r.clicked() { + let all = ui.input(|i| i.modifiers.shift); + if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + // A word in the notes: change that line of the slide's notes. + let si = slide.as_u64().unwrap_or(0) as usize; + let notes = + app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); + let (pi, st) = ( + item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, + item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, + ); + let lines: Vec = notes + .split('\n') + .enumerate() + .map(|(i, l)| { + if i != pi { + return l.to_string(); + } + let c: Vec = l.chars().collect(); + let n = w.chars().count(); + if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { + c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() + } else { + l.to_string() + } + }) + .collect(); + run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); + } else { + let mut p = if all { + json!({"word": w, "to": to, "all": true}) + } else { + json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) + }; + if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + } + }); + } + } + }); + if let Some(w) = add_ignore { + d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "accessibility" => { + let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + if list.is_empty() { + ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for item in &list { + ui.horizontal(|ui| { + ui.label(format!( + "Slide {}: {}{}", + item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, + item.get("issue").and_then(Value::as_str).unwrap_or(""), + item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() + )); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = item.get("shape") { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + } + }); + } + }); + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "language" => { + // Starts from the selected text's language and Do Not Check Spelling. + let (cur, np) = selected_language(app); + let lang = d.get("lang", &cur); + let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; + // The selected text, or every text in the presentation (all there is with nothing selected). + let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); + let mut all = !has_sel || d.get("scope", "") == "all"; + ui.horizontal(|ui| { + ui.label("Apply to:"); + if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { + d.fields.insert("scope".into(), "sel".into()); + all = false; + } + if ui.radio(all, "Whole presentation").clicked() { + d.fields.insert("scope".into(), "all".into()); + all = true; + } + }); + ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + // The selection's own language first when the list doesn't have it. + let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); + let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); + for (_native, tag) in all { + // Native names are shown when the interface fonts can draw them; otherwise + // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). + let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); + let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); + let label = if installed { format!("{name} (dictionary)") } else { name }; + if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { + d.fields.insert("lang".into(), tag); + } + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); + if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("lang", &cur); + run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof})); + } + ok || cancel + } + "customShow" => { + let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); + let mut started = false; + for s in &shows { + ui.horizontal(|ui| { + ui.label(format!("{} ({} slides)", s.name, s.slides.len())); + if ui.small_button("Show").clicked() { + run(app, "show.start", json!({"custom": s.name})); + started = true; + } + if ui.small_button("Delete").clicked() { + let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); + } + }); + } + if started { + return true; + } + field(ui, d, "New show name:", "name", "Custom Show 1"); + field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); + let (ok, cancel) = buttons(ui, "Create"); + if ok { + let idx: Vec = + d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); + run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); + } + ok || cancel + } + "chartData" => { + let ch = app.session.active().and_then(|s| { + s.selected_shapes() + .into_iter() + .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) + }); + let Some((c, id)) = ch else { + ui.label("Select a chart first."); + let (ok, cancel) = buttons(ui, "Close"); + return ok || cancel; + }; + ui.label("Edit the data: first row = series names, first column = categories."); + let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { + let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; + for (i, cat) in c.categories.iter().enumerate() { + let mut row = vec![cat.clone()]; + for s in &c.series { + row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); + } + g.push(row); + } + g + }); + egui::Grid::new("chartdata").striped(true).show(ui, |ui| { + for row in grid.iter_mut() { + for cell in row.iter_mut() { + ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); + } + ui.end_row(); + } + }); + ui.horizontal(|ui| { + if ui.button("+ Row").clicked() { + let n = grid.first().map(Vec::len).unwrap_or(2); + grid.push(vec![String::new(); n]); + } + if ui.button("+ Series").clicked() { + for r in grid.iter_mut() { + r.push(String::new()); + } + } + }); + d.params = json!({"grid": grid}); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); + let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); + let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); + run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); + } + ok || cancel + } + "trim" => { + let seed = trim_seed(app.session.active()); + field(ui, d, "Start time (s):", "start", &seed.start); + field(ui, d, "End trim (s):", "end", &seed.end); + field(ui, d, "Fade in (s):", "fin", &seed.fade_in); + field(ui, d, "Fade out (s):", "fout", &seed.fade_out); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run( + app, + "media.options", + json!({ + "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), + "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), + "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), + "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), + }), + ); + } + ok || cancel + } + "smartart" => { + ui.label("Pick a graphic; type one item per line."); + let kind = d.get("kind", "process"); + ui.horizontal_wrapped(|ui| { + for (k, l) in [ + ("list", "Basic List"), + ("process", "Basic Process"), + ("cycle", "Basic Cycle"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Pyramid"), + ("matrix", "Matrix"), + ] { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let mut v = d.get("text", "Plan\nBuild\nLaunch"); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + app.ui.pane = Some("smartart".into()); + } + ok || cancel + } + "about" => { + let tab = d.get("tab", "about"); + ui.horizontal(|ui| { + for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + match tab.as_str() { + "contributors" | "models" => { + credits_block(ui, tab == "models"); + } + _ => about(app, ui), + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "autocorrect" => { + let pr = &mut app.session.prefs; + ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); + ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); + ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); + ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); + ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); + ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); + ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); + ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); + ui.add_space(4.0); + let mut from = d.get("from", ""); + let mut to = d.get("to", ""); + ui.horizontal(|ui| { + let l = ui.label("Replace"); + ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); + let l = ui.label("With"); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); + from.clear(); + to.clear(); + } + }); + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to); + let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); + let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let q = from.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + egui::Grid::new("ac-list").striped(true).show(ui, |ui| { + for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { + ui.label(k); + ui.label(v); + if ui.small_button("Delete").clicked() { + run(app, "text.autocorrect", json!({"remove": k})); + } + ui.end_row(); + } + }); + }); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } + "gridOptions" => grid_options(app, ui, d), + "columns" => columns_dialog(app, ui, d), + "preferences" => { + // The controls apply as they change, so the dialog remembers what it opened with and + // Cancel (or Esc, or the close button: see `show`) puts that back. + if d.params.get("before").is_none() { + d.params = json!({ "before": preferences_snapshot(app) }); + } + ui.label(egui::RichText::new("General").font(theme::bold(13.0))); + let mut dark = app.ui.brightness == theme::Brightness::Dark; + if ui.checkbox(&mut dark, "Dark appearance").changed() { + let _ = app.run("view.dark", json!({"on": dark})); + } + // While the pointer holds the slider its value only waits in the dialog: applying it + // would rescale the slider under the pointer and run it to an end (#767). It applies + // on release, and at once from the keyboard. + let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); + let mut scale = pending.unwrap_or(app.ui.ui_scale); + let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); + // No visible label here (the row is laid out to fit); the accessible name only. + let enabled = r.enabled(); + r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); + if r.is_pointer_button_down_on() { + if r.changed() || pending.is_some() { + d.fields.insert("uiScale".into(), scale.to_string()); + } + } else { + if r.changed() || pending.is_some() { + app.ui.ui_scale = scale; + } + d.fields.remove("uiScale"); + } + // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. + ui.horizontal(|ui| { + ui.label("Measurement units:"); + let units = &mut app.session.prefs.units; + ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); + ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); + }); + ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); + ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); + ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); + ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); + ui.horizontal(|ui| { + ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + if ui.button("AutoCorrect Options…").clicked() { + *d = Dialog::new("autocorrect"); + } + }); + if d.id != "preferences" { + return false; + } + ui.horizontal(|ui| { + let label = ui.label("Your name (comments):"); + // Name the field for assistive technology; a bare text edit next to a label is an + // unnamed input in egui 0.37's accessibility tree. + let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); + }); + let (ok, cancel) = buttons(ui, "Done"); + if cancel { + restore_preferences(app, d); + } + ok || cancel + } + "photoAlbum" => photo_album_dialog(app, ui, d), + "themeColors" => theme_colors_dialog(app, ui, d), + "themeFonts" => theme_fonts_dialog(app, ui, d), + "dateTime" => date_time_dialog(app, ui, d), + _ => { + ui.label(format!("“{}” is not available yet.", d.id)); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } + } +} diff --git a/crates/ui-egui/src/dialogs/columns.rs b/crates/ui-egui/src/dialogs/columns.rs new file mode 100644 index 000000000..3e40916d9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/columns.rs @@ -0,0 +1,56 @@ +use super::*; + +/// The Columns dialog's changed fields as `format.columns` params: Number and Spacing (inches, +/// sent in points). A field left as the dialog showed it is left out, so each selected text box +/// keeps its own value; `None` when neither field changed. +fn columns_value(d: &Dialog, shown_count: &str, shown_spacing: &str) -> Result, String> { + let mut p = serde_json::Map::new(); + let count = field_of(d, "count"); + if count != shown_count { + let n = count.trim().parse::().ok().filter(|n| (1..=16).contains(n)).ok_or("Number must be a whole number from 1 to 16")?; + p.insert("count".into(), json!(n)); + } + let spacing = field_of(d, "spacing"); + if spacing != shown_spacing { + let inches = spacing + .trim() + .trim_end_matches("in") + .trim_end_matches('"') + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && (0.0..=22.0).contains(v)) + .ok_or("Spacing must be from 0 to 22 inches")?; + p.insert("spacing".into(), json!(inches * 72.0)); + } + Ok((!p.is_empty()).then_some(Value::Object(p))) +} + +/// Columns: the number of columns in the text box and the space between them. OK applies one +/// `format.columns` call with the fields that changed, or none when neither did; Cancel leaves +/// the text box as it was. +pub(super) fn columns_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let count = d.params.get("count").and_then(Value::as_u64).unwrap_or(1).to_string(); + let spacing = num_text(d.params.get("spacing").and_then(Value::as_f64).unwrap_or(0.0) / 72.0); + field(ui, d, "Number:", "count", &count); + field(ui, d, "Spacing (in):", "spacing", &spacing); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let applied = columns_value(d, &count, &spacing).and_then(|p| match p { + Some(p) => app.run("format.columns", p).map(|_| ()), + None => Ok(()), + }); + match applied { + Ok(()) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/date_time.rs b/crates/ui-egui/src/dialogs/date_time.rs new file mode 100644 index 000000000..2f78dbc32 --- /dev/null +++ b/crates/ui-egui/src/dialogs/date_time.rs @@ -0,0 +1,83 @@ +use super::*; + +const DATE_LANGS: &[(&str, &str)] = &[ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("fr-FR", "French (France)"), + ("ja-JP", "Japanese"), + ("sv-SE", "Swedish"), +]; + +/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only; +/// other languages keep the numeric date and the 24-hour times. +pub(super) fn date_rows(lang: &str) -> Vec<(&'static str, String)> { + let now = deckcraft_text::datetime::DateTime::now_local(); + let mut rows = Vec::new(); + for kind in DATE_KINDS { + if let Some(now) = now + && let Some(sample) = deckcraft_text::datetime::field_text(kind, Some(lang), now) + { + rows.push((kind, sample)); + } + } + if rows.is_empty() { + rows.push(("datetime1", "datetime1".into())); + } + rows +} + +/// Render language and format controls; OK applies `insert.dateTime`, and the return value says +/// whether the dialog should close. +pub(super) fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(440.0); + ui.label("Available formats:"); + let lang = d.get("language", "en-US"); + let rows = date_rows(&lang); + if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1")) + && let Some((k, _)) = rows.first() + { + d.fields.insert("format".into(), (*k).to_string()); + } + let format = d.get("format", "datetime1"); + egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(220.0).auto_shrink([false, false]).show(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(format == *kind, sample).clicked() { + d.fields.insert("format".into(), (*kind).to_string()); + } + } + }); + let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); + ui.horizontal(|ui| { + ui.label("Language:"); + let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { + for (k, l) in DATE_LANGS { + if ui.selectable_label(lang == *k, *l).clicked() { + d.fields.insert("language".into(), (*k).to_string()); + } + } + }); + let shown = lang_label.to_string(); + opened.response.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Language"); + info.current_text_value = Some(shown.clone()); + info + }); + }); + ui.label( + egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), + ); + let update = check(ui, d, "Update automatically", "update", true); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("language", "en-US"); + let mut format = d.get("format", "datetime1"); + let probe = deckcraft_text::datetime::DateTime::new(2026, 10, 9, 20, 5, 7); + if deckcraft_text::datetime::field_text(&format, Some(&lang), probe).is_none() { + format = "datetime1".into(); + } + return app.run("insert.dateTime", json!({"format": format, "update": update, "language": lang})).is_ok(); + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/equation.rs b/crates/ui-egui/src/dialogs/equation.rs new file mode 100644 index 000000000..23eae4928 --- /dev/null +++ b/crates/ui-egui/src/dialogs/equation.rs @@ -0,0 +1,41 @@ +pub(super) fn pretty_equation(s: &str) -> String { + let sup = |c: char| match c { + '0' => '⁰', + '1' => '¹', + '2' => '²', + '3' => '³', + '4' => '⁴', + '5' => '⁵', + '6' => '⁶', + '7' => '⁷', + '8' => '⁸', + '9' => '⁹', + 'n' => 'ⁿ', + 'i' => 'ⁱ', + '+' => '⁺', + '-' => '⁻', + _ => c, + }; + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '^' => { + while let Some(&n) = chars.peek() { + if n.is_ascii_alphanumeric() || n == '+' || n == '-' { + out.push(sup(n)); + chars.next(); + if !n.is_ascii_digit() { + break; + } + } else { + break; + } + } + } + '*' => out.push('·'), + _ => out.push(c), + } + } + out.replace("sqrt", "√").replace("<=", "≤").replace(">=", "≥").replace("!=", "≠").replace("pi", "π").replace("+-", "±") +} diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs new file mode 100644 index 000000000..d69e247c3 --- /dev/null +++ b/crates/ui-egui/src/dialogs/export.rs @@ -0,0 +1,60 @@ +use super::*; + +/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden +/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. +pub(super) fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result { + use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS}; + let n = app.session.active().map_or(0, |s| s.doc.slides.len()); + let size = d.get("gifSize", "medium"); + let size_label = |k: &str| match k { + "small" => "Small (640 px wide)", + "large" => "Large (1280 px wide)", + "extraLarge" => "Extra Large (1920 px wide)", + "custom" => "Custom width", + _ => "Medium (960 px wide)", + }; + ui.add_space(4.0); + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); + let combo = egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { + for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { + if ui.selectable_label(size == k, size_label(k)).clicked() { + d.fields.insert("gifSize".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + if size == "custom" { + field(ui, d, "Width (pixels):", "gifWidth", "960"); + } + field(ui, d, "Seconds per slide:", "gifSeconds", "5"); + let n_text = n.max(1).to_string(); + field(ui, d, "From slide:", "gifFrom", "1"); + field(ui, d, "To slide:", "gifTo", &n_text); + let forever = check(ui, d, "Loop forever", "gifLoop", true); + let hidden = check(ui, d, "Include hidden slides", "gifHidden", false); + + let width = if size == "custom" { + let w = num(d, "gifWidth", 960.0).round(); + if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) { + return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels")); + } + json!(w as u32) + } else { + json!(size) + }; + let seconds = num(d, "gifSeconds", f64::NAN); + if deckcraft_engine::gif_export::check_seconds(seconds).is_err() { + return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}")); + } + let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden}); + let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round()); + if !(from >= 1.0 && from <= to && to <= n as f64) { + return Err(format!("Slides: choose a range from 1 to {n}")); + } + if from > 1.0 || to < n as f64 { + p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::>()); + } + Ok(p) +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs new file mode 100644 index 000000000..c9a04f662 --- /dev/null +++ b/crates/ui-egui/src/dialogs/find.rs @@ -0,0 +1,91 @@ +use super::*; + +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + // "Find what:" has the keyboard when the dialog opens, so Ctrl+F and typing finds (#951). + let mut what = d.get("text", ""); + ui.horizontal(|ui| { + ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Find what:")); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(180.0)); + // Enter finds the next match and leaves the keyboard here, so the search can be changed. + if !d.fields.contains_key("focused") || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + r.request_focus(); + d.fields.insert("focused".into(), "1".into()); + } + }); + d.fields.insert("text".into(), what); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} diff --git a/crates/ui-egui/src/dialogs/font.rs b/crates/ui-egui/src/dialogs/font.rs new file mode 100644 index 000000000..7e2eabf7c --- /dev/null +++ b/crates/ui-egui/src/dialogs/font.rs @@ -0,0 +1,162 @@ +use super::*; + +fn seed_font_fields(d: &mut Dialog) { + seed_once(d, "family", json_text(d.params.get("family"), "")); + seed_once(d, "size", json_text(d.params.get("size"), "18")); + seed_once(d, "bold", json_text(d.params.get("bold"), "0")); + seed_once(d, "italic", json_text(d.params.get("italic"), "0")); + seed_once(d, "underline", json_text(d.params.get("underline"), "none")); + seed_once(d, "strike", json_text(d.params.get("strike"), "none")); + seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); + seed_once(d, "caps", json_text(d.params.get("caps"), "none")); + seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); + let shown = d.params.get("color").map(color_label).unwrap_or_default(); + seed_once(d, "color", shown.clone()); + seed_once(d, "colorSeed", shown); +} + +/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. +pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_font_fields(d); + let mut fams = crate::ribbon::font_families(app); + let family = d.get("family", ""); + if !family.is_empty() && !fams.iter().any(|f| f == &family) { + fams.insert(0, family.clone()); + } + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + let combo = egui::ComboBox::from_id_salt("font_dialog_family") + .selected_text(if family.is_empty() { " " } else { family.as_str() }) + .width(300.0) + .show_ui(ui, |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(family == *f, f).clicked() { + d.fields.insert("family".into(), f.clone()); + } + } + }); + }); + let _ = combo.response.labelled_by(field.id); + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Size", "size", 64.0); + ui.add_space(16.0); + check(ui, d, "Bold", "bold", false); + check(ui, d, "Italic", "italic", false); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + choice( + ui, + d, + "font_dialog_underline", + "Underline", + "underline", + &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], + ); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "Color", "color", 110.0); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + choice(ui, d, "font_dialog_strike", "Strikethrough", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + choice(ui, d, "font_dialog_baseline", "Baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + ui.add_space(8.0); + ui.label("Caps"); + choice(ui, d, "font_dialog_caps", "Caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + let mut spacing = d.get("spacing", "0"); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let mut picked = None; + let combo = egui::ComboBox::from_id_salt("font_dialog_spacing") + .selected_text(match spacing.as_str() { + "-3" => "Very tight", + "-1.5" => "Tight", + "0" => "Normal", + "3" => "Loose", + "6" => "Very loose", + other => other, + }) + .width(120.0) + .show_ui(ui, |ui| { + for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { + if ui.selectable_label(spacing == v, l).clicked() { + picked = Some(v.to_string()); + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Spacing")); + let _ = edited; + if let Some(v) = picked { + d.fields.insert("spacing".into(), v); + } else { + d.fields.insert("spacing".into(), spacing); + } + }); + let bold = d.get("bold", "0") == "1"; + let italic = d.get("italic", "0") == "1"; + let underline = d.get("underline", "none"); + let strike = d.get("strike", "none"); + let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; + let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + if bold { + sample = sample.strong(); + } + if italic { + sample = sample.italics(); + } + if underline != "none" { + sample = sample.underline(); + } + if strike != "none" { + sample = sample.strikethrough(); + } + ui.add_space(6.0); + ui.label(sample); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let family = d.get("family", ""); + let family = family.trim(); + if family.is_empty() { + app.set_status("Choose a font"); + return false; + } + let Some(size) = finite_pt(&d.get("size", "")) else { + app.set_status("Font size must be a number"); + return false; + }; + let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { + app.set_status("Character spacing must be a number"); + return false; + }; + let mut params = json!({ + "family": family, + "size": size, + "bold": d.get("bold", "0") == "1", + "italic": d.get("italic", "0") == "1", + "underline": d.get("underline", "none"), + "strike": d.get("strike", "none"), + "baseline": d.get("baseline", "none"), + "caps": d.get("caps", "none"), + "spacing": spacing, + }); + let color = d.get("color", ""); + let seed = d.get("colorSeed", ""); + if color == seed { + if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { + params["color"] = c.clone(); + } + } else if !color.trim().is_empty() { + params["color"] = json!(color.trim()); + } + let _ = app.run("format.fontDialog", params); + } + ok || cancel +} diff --git a/crates/ui-egui/src/dialogs/grid_options.rs b/crates/ui-egui/src/dialogs/grid_options.rs new file mode 100644 index 000000000..f90442f3f --- /dev/null +++ b/crates/ui-egui/src/dialogs/grid_options.rs @@ -0,0 +1,38 @@ +use super::*; + +fn format_spacing(v: f64) -> String { + if !v.is_finite() { + return "6".into(); + } + if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } +} + +/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one +/// `view.gridOptions` call; Cancel leaves the presentation as it was. +pub(super) fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); + let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); + let spacing_default = format_spacing(app.session.prefs.grid_spacing); + field(ui, d, "Spacing (pt):", "spacing", &spacing_default); + ui.add_space(6.0); + ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); + let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let spacing = num(d, "spacing", app.session.prefs.grid_spacing); + match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { + Ok(_) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/hyperlink.rs b/crates/ui-egui/src/dialogs/hyperlink.rs new file mode 100644 index 000000000..dd9be2895 --- /dev/null +++ b/crates/ui-egui/src/dialogs/hyperlink.rs @@ -0,0 +1,181 @@ +use super::*; + +/// What Insert Hyperlink would edit on the current selection. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum CurrentLink { + /// Nothing is linked. OK sets a new link (a caret at a word boundary inserts text). + None, + /// One link covers the text span or every selected shape. + One(deckcraft_model::text::Hyperlink), + /// The selection mixes different links. OK must not replace them until a new target is chosen. + Mixed, +} + +/// Fields the Insert Hyperlink dialog opens with. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct HyperlinkSeed { + /// `url`, `email` or `slide`. + pub(super) mode: &'static str, + pub(super) url: String, + pub(super) email: String, + pub(super) slide: usize, + pub(super) tip: String, + pub(super) highlight: bool, + /// The action is not a web address, email or slide this dialog edits (or the selection mixes + /// links). OK leaves it in place until the user picks a different target. + pub(super) preserve: bool, +} + +impl HyperlinkSeed { + /// A new link: the address starts at `https://`, as the dialog always did. + pub(super) fn blank() -> Self { + HyperlinkSeed { mode: "url", url: "https://".into(), email: String::new(), slide: 0, tip: String::new(), highlight: false, preserve: false } + } +} + +/// Turn one hyperlink into dialog fields. A slide that is no longer in the deck, and actions this +/// dialog does not edit (next slide, a program, …), are preserved so OK does not retarget them. +pub(super) fn seed_of_link(link: &deckcraft_model::text::Hyperlink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { + use deckcraft_model::text::Action; + let mut seed = HyperlinkSeed { + mode: "url", + url: String::new(), + email: String::new(), + slide: 0, + tip: link.tooltip.clone(), + highlight: link.highlight_click, + preserve: true, + }; + match &link.action { + Action::Url { url } => { + seed.preserve = false; + if let Some(email) = url.strip_prefix("mailto:") { + seed.mode = "email"; + seed.email = email.to_string(); + seed.url = "https://".into(); + } else { + seed.url = url.clone(); + } + } + Action::Slide { slide } => { + if let Some(i) = slide_ids.iter().position(|id| id == slide) { + seed.preserve = false; + seed.mode = "slide"; + seed.slide = i; + seed.url = "https://".into(); + } + } + _ => {} + } + seed +} + +pub(super) fn seed_from(current: CurrentLink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed { + match current { + CurrentLink::None => HyperlinkSeed::blank(), + CurrentLink::Mixed => HyperlinkSeed { preserve: true, url: String::new(), ..HyperlinkSeed::blank() }, + CurrentLink::One(link) => seed_of_link(&link, slide_ids), + } +} + +/// Links on the half-open span `[a, b)`. A point span is a new link: Insert Hyperlink inserts +/// there instead of editing a neighbouring run. +pub(super) fn link_covering(body: &deckcraft_model::text::TextBody, a: (usize, usize), b: (usize, usize)) -> CurrentLink { + if a == b { + return CurrentLink::None; + } + let mut seen: Option> = None; + for (pi, para) in body.paragraphs.iter().enumerate() { + if pi < a.0 || pi > b.0 { + continue; + } + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { usize::MAX }; + if from >= to { + continue; + } + let mut acc = 0usize; + let mut covered = false; + for run in ¶.runs { + let len = run.char_len(); + let end = acc + len; + if len > 0 && end > from && acc < to { + covered = true; + match &seen { + None => seen = Some(run.props.link.clone()), + Some(prev) if prev == &run.props.link => {} + Some(_) => return CurrentLink::Mixed, + } + } + acc = end; + } + if !covered && para.char_len() == 0 { + match &seen { + None => seen = Some(para.end_props.link.clone()), + Some(prev) if prev == ¶.end_props.link => {} + Some(_) => return CurrentLink::Mixed, + } + } + } + match seen { + Some(Some(link)) => CurrentLink::One(link), + _ => CurrentLink::None, + } +} + +pub(super) fn common_click(clicks: impl IntoIterator>) -> CurrentLink { + let mut clicks = clicks.into_iter(); + let Some(first) = clicks.next() else { return CurrentLink::None }; + for next in clicks { + if next != first { + return CurrentLink::Mixed; + } + } + match first { + Some(link) => CurrentLink::One(link), + None => CurrentLink::None, + } +} + +/// The link Insert Hyperlink would edit: text when a caret or selection is active, otherwise the +/// click action shared by the selected shapes. +pub(super) fn hyperlink_seed_of(st: Option<&deckcraft_engine::DocState>) -> HyperlinkSeed { + let Some(st) = st else { return HyperlinkSeed::blank() }; + let ids: Vec = st.doc.slides.iter().map(|s| s.id).collect(); + let current = if let Some(t) = &st.selection.text { + match deckcraft_engine::cmd::text::body_of(st, t) { + Some(body) => { + let (a, b) = deckcraft_engine::cmd::text::format_range(st, t); + link_covering(body, a, b) + } + None => CurrentLink::None, + } + } else { + common_click(st.selected_shapes().into_iter().map(|sh| sh.click.clone())) + }; + seed_from(current, &ids) +} + +/// OK writes a link unless the dialog is holding an action it does not edit and the user has not +/// chosen a new target. +pub(super) fn hyperlink_should_apply(seed: &HyperlinkSeed, mode: &str, url: &str, email: &str, slide: usize) -> bool { + if !seed.preserve { + return true; + } + if mode != seed.mode { + return true; + } + match mode { + "email" => email != seed.email, + "slide" => slide != seed.slide, + _ => url != seed.url, + } +} + +pub(super) fn hyperlink_command(mode: &str, url: &str, email: &str, slide: usize, tip: &str, highlight: bool) -> Value { + match mode { + "slide" => json!({"slide": slide, "tooltip": tip, "highlightClick": highlight}), + "email" => json!({"url": format!("mailto:{email}"), "tooltip": tip, "highlightClick": highlight}), + _ => json!({"url": url, "tooltip": tip, "highlightClick": highlight}), + } +} diff --git a/crates/ui-egui/src/dialogs/language.rs b/crates/ui-egui/src/dialogs/language.rs new file mode 100644 index 000000000..57536fb7e --- /dev/null +++ b/crates/ui-egui/src/dialogs/language.rs @@ -0,0 +1,28 @@ +use super::*; + +/// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at +/// the caret, else the first run of the first selected shape; US English when none says. +pub fn selected_language(app: &SlideApp) -> (String, bool) { + let Some(st) = app.session.active() else { return ("en-US".into(), false) }; + let run = |sh: &deckcraft_model::Shape, at: Option<(usize, usize)>| -> Option { + let t = sh.text.as_ref()?; + if let Some((p, c)) = at { + let para = t.paragraphs.get(p)?; + let mut n = 0; + for r in ¶.runs { + n += r.char_len(); + if c <= n { + return Some(r.props.clone()); + } + } + return para.runs.last().map(|r| r.props.clone()); + } + t.paragraphs.iter().flat_map(|p| &p.runs).next().map(|r| r.props.clone()) + }; + let props = match &st.selection.text { + Some(t) if !t.notes && t.cell.is_none() => st.shape(t.shape).and_then(|sh| run(sh, Some(t.caret))), + _ => st.selection.shapes.first().and_then(|id| st.shape(*id)).and_then(|sh| run(sh, None)), + }; + let props = props.unwrap_or_default(); + (props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true)) +} diff --git a/crates/ui-egui/src/dialogs/paragraph.rs b/crates/ui-egui/src/dialogs/paragraph.rs new file mode 100644 index 000000000..d442c8251 --- /dev/null +++ b/crates/ui-egui/src/dialogs/paragraph.rs @@ -0,0 +1,197 @@ +use super::*; + +fn seed_paragraph_fields(d: &mut Dialog) { + seed_once(d, "align", json_text(d.params.get("align"), "left")); + seed_once(d, "before", inches_text(d.params.get("indentLeft"))); + seed_once(d, "after", inches_text(d.params.get("indentRight"))); + seed_once(d, "special", json_text(d.params.get("special"), "none")); + seed_once(d, "by", inches_text(d.params.get("specialBy"))); + seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); + seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); +} + +fn paragraph_return_params(d: &mut Dialog) -> Value { + let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; + let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; + let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; + let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); + let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); + let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); + let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); + let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); + json!({ + "align": d.get("align", "left"), + "indentLeft": before, + "indentRight": after, + "special": d.get("special", "none"), + "specialBy": by, + "spaceBefore": space_before, + "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfter": space_after, + "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "lineKind": d.get("lineKind", "multiple"), + "lineValue": line_value, + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + }) +} + +fn line_preset_label(kind: &str, value: &str) -> &'static str { + if kind == "exactly" { + "Exactly" + } else { + match value { + "1" => "Single", + "1.5" => "1.5 lines", + "2" => "Double", + _ => "Multiple", + } + } +} + +/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. +pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_paragraph_fields(d); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + choice( + ui, + d, + "paragraph_align", + "Alignment", + "align", + &[ + ("left", "Left"), + ("center", "Center"), + ("right", "Right"), + ("justify", "Justify"), + ("justLow", "Justify low"), + ("distributed", "Distributed"), + ], + ); + }); + ui.label(egui::RichText::new("Indentation").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Before text", "before", 64.0); + ui.label("in"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "After text", "after", 64.0); + ui.label("in"); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + choice(ui, d, "paragraph_special", "Special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "By", "by", 64.0); + ui.label("in"); + }); + ui.label(egui::RichText::new("Spacing").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); + ui.label("pt"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); + ui.label("pt"); + }); + let mut picked: Option<(&'static str, Option<&'static str>)> = None; + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let kind = d.get("lineKind", "multiple"); + let value = d.get("lineValue", "1"); + let combo = egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } + } + }); + let _ = combo.response.labelled_by(field.id); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "At", "lineValue", 64.0); + }); + if let Some((kind, value)) = picked { + d.fields.insert("lineKind".into(), kind.into()); + if let Some(v) = value { + d.fields.insert("lineValue".into(), v.into()); + } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { + d.fields.insert("lineValue".into(), "18".into()); + } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { + d.fields.insert("lineValue".into(), "1".into()); + } + } + let mut open_tabs = false; + ui.horizontal(|ui| { + if ui.button("Tabs…").clicked() { + open_tabs = true; + } + }); + let (ok, cancel) = buttons(ui, "OK"); + if open_tabs { + let mut tabs = Dialog::new("tabs"); + tabs.params = json!({ + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + "returnId": "paragraph", + "returnParams": paragraph_return_params(d), + }); + app.dialog = Some(tabs); + return true; + } + if ok { + let Some(before_in) = finite_pt(&d.get("before", "")) else { + app.set_status("Indent before text must be a number of inches"); + return false; + }; + let Some(after_in) = finite_pt(&d.get("after", "")) else { + app.set_status("Indent after text must be a number of inches"); + return false; + }; + let Some(by_in) = finite_pt(&d.get("by", "")) else { + app.set_status("Special indent must be a number of inches"); + return false; + }; + let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { + app.set_status("Spacing before must be a number"); + return false; + }; + let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { + app.set_status("Spacing after must be a number"); + return false; + }; + let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { + app.set_status("Line spacing must be a number"); + return false; + }; + let mut params = json!({ + "align": d.get("align", "left"), + "indentLeft": before_in * 72.0, + "indentRight": after_in * 72.0, + "special": d.get("special", "none"), + "specialBy": by_in * 72.0, + "spaceBefore": space_before, + "spaceAfter": space_after, + "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + }); + if d.get("lineKind", "multiple") == "exactly" { + params["lineSpacingPt"] = json!(line_value); + } else { + params["lineSpacing"] = json!(line_value); + } + if app.run("format.paragraphDialog", params).is_err() { + return false; + } + } + ok || cancel +} diff --git a/crates/ui-egui/src/dialogs/photo_album.rs b/crates/ui-egui/src/dialogs/photo_album.rs new file mode 100644 index 000000000..84c6f5b9b --- /dev/null +++ b/crates/ui-egui/src/dialogs/photo_album.rs @@ -0,0 +1,339 @@ +use super::*; + +fn album_items(d: &Dialog) -> Option> { + let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]"); + serde_json::from_str::(raw).ok().and_then(|v| v.as_array().cloned()) +} + +fn store_album_items(d: &mut Dialog, items: &[Value]) { + let raw = serde_json::to_string(items).unwrap_or_else(|_| "[]".into()); + d.fields.insert("items".into(), raw); +} + +fn album_item_label(v: &Value) -> String { + if let Some(text) = v.get("text").and_then(Value::as_str) { + let text = text.trim(); + if text.is_empty() { "Text Box".to_string() } else { format!("Text: {text}") } + } else { + v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("Picture").to_string() + } +} + +fn pick_album_picture(app: &mut SlideApp) -> Option { + let path = if let Some(pick) = app.services.pick_open.as_mut() { + pick("picture") + } else { + app.set_status("Pass pictures to insert.photoAlbum"); + None + }; + let path = path?; + let loaded = if let Some(read) = app.services.read.as_ref() { + match read(&path) { + Ok(bytes) => bytes, + Err(e) => { + app.set_status(e); + return None; + } + } + } else { + #[cfg(not(target_arch = "wasm32"))] + { + match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + app.set_status(format!("{path}: {e}")); + return None; + } + } + } + #[cfg(target_arch = "wasm32")] + { + app.set_status("The web build inserts album pictures from data passed to insert.photoAlbum"); + return None; + } + }; + if loaded.is_empty() { + app.set_status("That file is empty"); + return None; + } + if loaded.len() > 32 * 1024 * 1024 { + app.set_status("That picture is larger than 32 MB"); + return None; + } + let name = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).filter(|s| !s.is_empty()).unwrap_or("picture").to_string(); + Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)})) +} + +/// Draw an accessible album combo box, preserving its current/default selection and storing a +/// newly chosen key; the caller supplies the visible label. +fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &str, key: &str, default: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, default); + let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); + let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { + for (k, l) in choices { + if ui.selectable_label(cur == *k, *l).clicked() { + d.fields.insert(key.into(), (*k).to_string()); + } + } + }); + let access = name.to_string(); + let shown = label.to_string(); + opened.response.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone()); + info.current_text_value = Some(shown.clone()); + info + }); +} + +const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]; + +fn current_theme(app: &SlideApp) -> String { + app.session + .active() + .and_then(|d| d.doc.masters.first()) + .map(|m| m.theme.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "DeckCraft".into()) +} + +fn album_json_id(v: &Value) -> Option { + v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) +} + +/// Copy a command's dialog params into fields once. A fresh album has no `items`. +fn seed_photo_album(app: &SlideApp, d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + d.fields.insert("seeded".into(), "1".into()); + let p = d.params.clone(); + if let Some(items) = p.get("items").and_then(Value::as_array) { + store_album_items(d, items); + d.fields.insert("editing".into(), "1".into()); + } + for key in ["layout", "frame", "theme"] { + if let Some(v) = p.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) { + d.fields.insert(key.into(), v.to_string()); + } + } + for key in ["captions", "grayscale"] { + if let Some(v) = p.get(key).and_then(Value::as_bool) { + d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); + } + } + if let Some(id) = p.get("album").and_then(album_json_id).filter(|n| *n != 0) { + d.fields.insert("album".into(), id.to_string()); + } + let theme_was = d.fields.get("theme").cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| current_theme(app)); + if !d.fields.contains_key("theme") { + d.fields.insert("theme".into(), theme_was.clone()); + } + d.fields.entry("themeWas".into()).or_insert(theme_was); +} + +fn selected_album_picture(d: &Dialog, items: &[Value]) -> bool { + let i = d.fields.get("sel").and_then(|s| s.parse::().ok()).unwrap_or(0); + items.get(i).is_some_and(|item| item.get("data").is_some() || item.get("path").is_some()) +} + +fn with_selected_picture(d: &mut Dialog, f: impl FnOnce(&mut serde_json::Map)) { + let Some(mut list) = album_items(d) else { return }; + let i = d.fields.get("sel").and_then(|s| s.parse::().ok()).unwrap_or(0); + let Some(item) = list.get_mut(i) else { return }; + if item.get("data").is_none() && item.get("path").is_none() { + return; + } + let Some(obj) = item.as_object_mut() else { return }; + f(obj); + store_album_items(d, &list); +} + +/// Photo Album. Returns true when the dialog should close. +pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_photo_album(app, d); + ui.set_min_width(440.0); + egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| { + ui.set_min_width(420.0); + ui.label("Pictures in album"); + let items = album_items(d).unwrap_or_default(); + let mut sel = d.get("sel", "0").parse::().unwrap_or(0); + // Fixed height so adding pictures does not push Create past the window. + egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| { + if items.is_empty() { + ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim)); + } + for (i, item) in items.iter().enumerate() { + if ui.selectable_label(sel == i, album_item_label(item)).clicked() { + sel = i; + } + } + }); + d.fields.insert("sel".into(), sel.to_string()); + ui.horizontal(|ui| { + if ui.button("Add Pictures…").clicked() + && let Some(item) = pick_album_picture(app) + { + match album_items(d) { + Some(mut list) if list.len() < 48 => { + list.push(item); + let next = list.len().saturating_sub(1); + store_album_items(d, &list); + d.fields.insert("sel".into(), next.to_string()); + } + Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"), + None => app.set_status("The picture list is invalid"), + } + } + if ui.button("New Text Box").clicked() { + let text = d.get("boxText", "Text Box"); + let text = text.trim(); + if text.is_empty() { + app.set_status("Type the text for the new text box"); + } else if text.chars().count() > 400 { + app.set_status("Text is longer than 400 characters"); + } else if let Some(mut list) = album_items(d) { + if list.len() >= 48 { + app.set_status("A photo album holds at most 48 pictures or text boxes"); + } else { + list.push(json!({"text": text})); + let next = list.len().saturating_sub(1); + store_album_items(d, &list); + d.fields.insert("sel".into(), next.to_string()); + } + } + } + if ui.button("Remove").clicked() + && let Some(mut list) = album_items(d) + && let Some(i) = d.get("sel", "0").parse::().ok() + && i < list.len() + { + list.remove(i); + store_album_items(d, &list); + let next = i.min(list.len().saturating_sub(1)); + d.fields.insert("sel".into(), next.to_string()); + } + }); + field(ui, d, "Text box:", "boxText", "Text Box"); + let picture = selected_album_picture(d, &items); + ui.label(album_picture_status(&items, sel)); + ui.horizontal_wrapped(|ui| { + ui.add_enabled_ui(picture, |ui| { + if ui.button("Rotate Left").clicked() { + with_selected_picture(d, |obj| { + let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); + let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0; + obj.insert("rot".into(), json!(next)); + }); + } + if ui.button("Rotate Right").clicked() { + with_selected_picture(d, |obj| { + let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); + let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0; + obj.insert("rot".into(), json!(next)); + }); + } + if ui.button("More Brightness").clicked() { + nudge_picture(d, "brightness", 0.1); + } + if ui.button("Less Brightness").clicked() { + nudge_picture(d, "brightness", -0.1); + } + if ui.button("More Contrast").clicked() { + nudge_picture(d, "contrast", 0.1); + } + if ui.button("Less Contrast").clicked() { + nudge_picture(d, "contrast", -0.1); + } + }); + }); + ui.add_space(4.0); + ui.label("Picture layout"); + album_choice( + ui, + d, + "album_layout", + "Picture layout", + "layout", + "1", + &[ + ("fit", "Fit to slide"), + ("1", "1 picture"), + ("2", "2 pictures"), + ("4", "4 pictures"), + ("1title", "1 picture with title"), + ("2title", "2 pictures with title"), + ("4title", "4 pictures with title"), + ], + ); + ui.label("Frame shape"); + album_choice( + ui, + d, + "album_frame", + "Frame shape", + "frame", + "rect", + &[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")], + ); + check(ui, d, "Captions under pictures", "captions", false); + check(ui, d, "ALL pictures black and white", "grayscale", false); + ui.label("Album theme"); + album_choice(ui, d, "album_theme", "Album theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); + }); + let captions = d.fields.get("captions").is_some_and(|v| v == "1"); + let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1"); + let editing = d.fields.get("editing").is_some_and(|v| v == "1"); + let (ok, cancel) = buttons(ui, if editing { "Update" } else { "Create" }); + if ok { + let Some(list) = album_items(d) else { + app.set_status("The picture list is invalid"); + return false; + }; + if list.is_empty() { + app.set_status("Add a picture or a text box first"); + return false; + } + let layout = d.get("layout", "1"); + let frame = d.get("frame", "rect"); + let theme = d.get("theme", "DeckCraft"); + let theme_was = d.fields.get("themeWas").cloned().unwrap_or_default(); + let mut payload = json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions, "grayscale": grayscale}); + if theme != theme_was + && let Some(obj) = payload.as_object_mut() + { + obj.insert("theme".into(), json!(theme)); + } + if editing { + if let Some(album) = d.fields.get("album").filter(|s| !s.is_empty() && s.as_str() != "0") + && let Ok(id) = album.parse::() + && let Some(obj) = payload.as_object_mut() + { + obj.insert("album".into(), json!(id)); + } + return app.run("insert.photoAlbumEdit", payload).is_ok(); + } + return app.run("insert.photoAlbum", payload).is_ok(); + } + cancel +} + +fn album_picture_status(items: &[Value], sel: usize) -> String { + let Some(item) = items.get(sel) else { + return "Select a picture to rotate it or change brightness and contrast".into(); + }; + if item.get("data").is_none() && item.get("path").is_none() { + return "Text boxes have no picture options".into(); + } + let rot = item.get("rot").and_then(Value::as_f64).unwrap_or(0.0); + let brightness = item.get("brightness").and_then(Value::as_f64).unwrap_or(0.0); + let contrast = item.get("contrast").and_then(Value::as_f64).unwrap_or(0.0); + format!("Rotation {rot:.0}°, brightness {brightness:.1}, contrast {contrast:.1}") +} + +fn nudge_picture(d: &mut Dialog, key: &str, delta: f64) { + with_selected_picture(d, |obj| { + let cur = obj.get(key).and_then(Value::as_f64).unwrap_or(0.0); + let next = (((cur + delta) * 10.0).round() / 10.0).clamp(-1.0, 1.0); + obj.insert(key.into(), json!(next)); + }); +} diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs new file mode 100644 index 000000000..13e962034 --- /dev/null +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -0,0 +1,26 @@ +use super::*; + +/// What the Preferences dialog can change, as it was when the dialog opened. +pub(super) fn preferences_snapshot(app: &SlideApp) -> Value { + json!({ + "dark": app.ui.brightness == theme::Brightness::Dark, + "uiScale": app.ui.ui_scale, + "prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null), + }) +} + +/// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button). +pub(super) fn restore_preferences(app: &mut SlideApp, d: &Dialog) { + let Some(before) = d.params.get("before") else { return }; + if let Some(dark) = before.get("dark").and_then(Value::as_bool) + && dark != (app.ui.brightness == theme::Brightness::Dark) + { + let _ = app.run("view.dark", json!({ "on": dark })); + } + if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) { + app.ui.ui_scale = scale as f32; + } + if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) { + app.session.prefs = prefs; + } +} diff --git a/crates/ui-egui/src/dialogs/size_position.rs b/crates/ui-egui/src/dialogs/size_position.rs new file mode 100644 index 000000000..8745203f9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/size_position.rs @@ -0,0 +1,207 @@ +use super::*; + +fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { + d.fields.insert(key.into(), shown.clone()); + d.fields.insert(format!("seed{key}"), shown.clone()); + d.fields.insert(format!("prev{key}"), shown); + d.fields.insert(format!("{key}Pt"), exact.to_string()); +} + +fn publish_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { + d.fields.insert(key.into(), shown.clone()); + d.fields.insert(format!("seed{key}"), shown); + d.fields.insert(format!("{key}Pt"), exact.to_string()); +} + +fn selection_xfrm(app: &SlideApp) -> Option { + let st = app.session.active()?; + let id = *st.selection.shapes.first()?; + let sh = st.shape(id)?; + Some(deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)) +} + +fn seed_size_position(app: &SlideApp, d: &mut Dialog) { + if d.fields.contains_key("baseW") { + return; + } + let num = |k: &str| d.params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let sel = selection_xfrm(app); + let x = num("x").or_else(|| sel.map(|b| b.x)).unwrap_or(0.0); + let y = num("y").or_else(|| sel.map(|b| b.y)).unwrap_or(0.0); + let w = num("w").or_else(|| sel.map(|b| b.w)).unwrap_or(72.0); + let h = num("h").or_else(|| sel.map(|b| b.h)).unwrap_or(72.0); + let rot = num("rot").or_else(|| sel.map(|b| b.rot)).unwrap_or(0.0); + let sw = num("scaleW").unwrap_or(100.0); + let sh = num("scaleH").unwrap_or(100.0); + let lock = d.params.get("lockAspect").and_then(Value::as_bool).unwrap_or(false); + put_dim(d, "x", x, num_text(x / 72.0)); + put_dim(d, "y", y, num_text(y / 72.0)); + put_dim(d, "w", w, num_text(w / 72.0)); + put_dim(d, "h", h, num_text(h / 72.0)); + put_dim(d, "rot", rot, num_text(rot)); + put_dim(d, "scaleW", sw, num_text(sw)); + put_dim(d, "scaleH", sh, num_text(sh)); + d.fields.insert("baseW".into(), w.to_string()); + d.fields.insert("baseH".into(), h.to_string()); + let bit = if lock { "1" } else { "0" }; + d.fields.insert("lock".into(), bit.into()); + d.fields.insert("prevlock".into(), bit.into()); +} + +fn scale_of(pt: f64, base: f64) -> f64 { + if base > 0.0 { pt / base * 100.0 } else { 100.0 } +} + +fn current_points(d: &Dialog, key: &str) -> Option { + if edited(d, key) { parse_shown(&field_of(d, key)).map(|inches| inches * 72.0) } else { exact_of(d, key) } +} + +/// Keep scale tied to size, and the other side tied when aspect is locked. +fn reconcile_size_fields(d: &mut Dialog) { + let lock = field_of(d, "lock") == "1"; + let was_locked = field_of(d, "prevlock") == "1"; + let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0); + let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0); + let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 }; + let (w_s, h_s, sw_s, sh_s) = (field_of(d, "w"), field_of(d, "h"), field_of(d, "scaleW"), field_of(d, "scaleH")); + let (prev_w, prev_h, prev_sw, prev_sh) = (field_of(d, "prevw"), field_of(d, "prevh"), field_of(d, "prevscaleW"), field_of(d, "prevscaleH")); + if w_s != prev_w { + if let Some(inches) = parse_shown(&w_s) { + let w_pt = inches * 72.0; + d.fields.insert("wPt".into(), w_pt.to_string()); + let pct = scale_of(w_pt, base_w); + publish_dim(d, "scaleW", pct, num_text(pct)); + if lock { + let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + let hp = scale_of(h_pt, base_h); + publish_dim(d, "scaleH", hp, num_text(hp)); + } + } + } else if h_s != prev_h { + if let Some(inches) = parse_shown(&h_s) { + let h_pt = inches * 72.0; + d.fields.insert("hPt".into(), h_pt.to_string()); + let pct = scale_of(h_pt, base_h); + publish_dim(d, "scaleH", pct, num_text(pct)); + if lock { + let w_pt = h_pt * ar; + publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); + let wp = scale_of(w_pt, base_w); + publish_dim(d, "scaleW", wp, num_text(wp)); + } + } + } else if sw_s != prev_sw { + if let Some(pct) = parse_shown(&sw_s) { + let w_pt = base_w * pct / 100.0; + d.fields.insert("scaleWPt".into(), pct.to_string()); + publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); + if lock { + let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + publish_dim(d, "scaleH", pct, num_text(pct)); + } + } + } else if sh_s != prev_sh { + if let Some(pct) = parse_shown(&sh_s) { + let h_pt = base_h * pct / 100.0; + d.fields.insert("scaleHPt".into(), pct.to_string()); + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + if lock { + let w_pt = h_pt * ar; + publish_dim(d, "w", w_pt, num_text(w_pt / 72.0)); + publish_dim(d, "scaleW", pct, num_text(pct)); + } + } + } else if lock + && !was_locked + && let Some(w_pt) = current_points(d, "w") + { + let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt }; + publish_dim(d, "h", h_pt, num_text(h_pt / 72.0)); + let hp = scale_of(h_pt, base_h); + publish_dim(d, "scaleH", hp, num_text(hp)); + let wp = scale_of(w_pt, base_w); + publish_dim(d, "scaleW", wp, num_text(wp)); + } + for key in ["x", "y", "w", "h", "rot", "scaleW", "scaleH"] { + let v = field_of(d, key); + d.fields.insert(format!("prev{key}"), v); + } + d.fields.insert("prevlock".into(), if lock { "1".into() } else { "0".into() }); +} + +fn shown_points(d: &Dialog, key: &str, label: &str) -> Result { + if edited(d, key) { + let n = parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"))?; + return Ok(n * 72.0); + } + exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) +} + +fn shown_number(d: &Dialog, key: &str, label: &str) -> Result { + if edited(d, key) { + return parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number")); + } + exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) +} + +fn size_position_value(d: &Dialog) -> Result { + let x = shown_points(d, "x", "Horizontal")?; + let y = shown_points(d, "y", "Vertical")?; + let rot = shown_number(d, "rot", "Rotation")?; + let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0); + let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0); + let mut w = if edited(d, "w") { + shown_points(d, "w", "Width")? + } else if edited(d, "scaleW") { + base_w * shown_number(d, "scaleW", "Scale width")? / 100.0 + } else { + shown_points(d, "w", "Width")? + }; + let mut h = if edited(d, "h") { + shown_points(d, "h", "Height")? + } else if edited(d, "scaleH") { + base_h * shown_number(d, "scaleH", "Scale height")? / 100.0 + } else { + shown_points(d, "h", "Height")? + }; + if field_of(d, "lock") == "1" { + let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 }; + let width_drives = edited(d, "w") || edited(d, "scaleW") || (!edited(d, "h") && !edited(d, "scaleH")); + if width_drives { + h = if ar > 0.0 { w / ar } else { h }; + } else { + w = h * ar; + } + } + Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"})) +} + +pub(super) fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_size_position(app, d); + ui.label(egui::RichText::new("Size").strong()); + text_row(ui, d, "Height (in):", "h"); + text_row(ui, d, "Width (in):", "w"); + text_row(ui, d, "Rotation (°):", "rot"); + text_row(ui, d, "Scale height (%):", "scaleH"); + text_row(ui, d, "Scale width (%):", "scaleW"); + check(ui, d, "Lock aspect ratio", "lock", false); + ui.add_space(8.0); + ui.label(egui::RichText::new("Position").strong()); + ui.label("From the top-left corner of the slide"); + text_row(ui, d, "Horizontal (in):", "x"); + text_row(ui, d, "Vertical (in):", "y"); + reconcile_size_fields(d); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + match size_position_value(d) { + Ok(p) => return app.run("shape.sizePosition", p).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/slide_size.rs b/crates/ui-egui/src/dialogs/slide_size.rs new file mode 100644 index 000000000..93e1e3bf0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/slide_size.rs @@ -0,0 +1,29 @@ +use super::*; + +/// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside. +pub(super) fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) { + d.fields.insert(key.into(), format!("{:.2}", pt / ppu)); + d.fields.insert(format!("{key}Pt"), pt.to_string()); +} + +/// A Slide Size field in points: the exact value while the text is still its rendering, else +/// the number typed (`default` when it is not one). +pub(super) fn slide_dim_points(d: &Dialog, key: &str, ppu: f64, default: f64) -> f64 { + let text = field_of(d, key); + if let Some(pt) = exact_of(d, key) + && text == format!("{:.2}", pt / ppu) + { + return pt; + } + text.trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).map_or(default, |v| v * ppu) +} + +/// The unit's abbreviation in a field label, "Width (cm):". +pub(super) fn unit_abbr(unit: deckcraft_geom::Unit) -> &'static str { + match unit { + deckcraft_geom::Unit::Inches => "in", + deckcraft_geom::Unit::Centimeters => "cm", + deckcraft_geom::Unit::Points => "pt", + deckcraft_geom::Unit::Pixels => "px", + } +} diff --git a/crates/ui-egui/src/dialogs/tabs.rs b/crates/ui-egui/src/dialogs/tabs.rs new file mode 100644 index 000000000..c0fa14783 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tabs.rs @@ -0,0 +1,91 @@ +use super::*; + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +pub(super) fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let l = ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); + } + } + ok || cancel +} diff --git a/crates/ui-egui/src/dialogs/theme_colors.rs b/crates/ui-egui/src/dialogs/theme_colors.rs new file mode 100644 index 000000000..fb00f5102 --- /dev/null +++ b/crates/ui-egui/src/dialogs/theme_colors.rs @@ -0,0 +1,95 @@ +use super::*; + +fn seed_theme_colors(d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); + d.fields.insert("schemeName".into(), name.to_string()); + let obj = d.params.get("colors").and_then(Value::as_object); + for slot in SchemeSlot::THEME { + let hex = obj.and_then(|o| o.get(slot.xml_name())).and_then(Value::as_str).unwrap_or("000000"); + d.fields.insert(slot.xml_name().into(), hex.to_string()); + } + d.fields.insert("seeded".into(), "1".into()); +} + +/// Draw one theme-colour row with its hex editor and store the value for the scheme slot. +fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { + let key = slot.xml_name(); + let mut v = d.get(key, "000000"); + ui.horizontal(|ui| { + ui.add_sized(vec2(188.0, 18.0), egui::Label::new(slot.label())); + let fill = deckcraft_color::Rgba::from_hex(&v).map(theme::to_color32).unwrap_or_else(|| Color32::from_gray(200)); + let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + ui.painter().rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); + let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key))); + let shown = v.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, format!("{} hex", slot.label())); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert(key.into(), v); +} + +fn theme_color_payload(d: &mut Dialog) -> Result { + let name = d.get("schemeName", "").trim().to_string(); + if name.is_empty() { + return Err("Enter a scheme name.".into()); + } + let mut colors = serde_json::Map::new(); + for slot in SchemeSlot::THEME { + let raw = d.get(slot.xml_name(), ""); + let Some(rgb) = deckcraft_color::Rgba::from_hex(raw.trim()) else { + return Err(format!("{} needs a colour like #RRGGBB.", slot.label())); + }; + colors.insert(slot.xml_name().into(), Value::String(format!("#{}", rgb.hex()))); + } + Ok(json!({"name": name, "colors": colors})) +} + +/// Create New Theme Colors. Returns true when the dialog should close. +pub(super) fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_theme_colors(d); + // The window keeps the height of its contents and will not grow after the first frame, so the + // rows stay short enough that Save is inside that rect. + ui.style_mut().spacing.interact_size.y = 16.0; + ui.style_mut().spacing.item_spacing.y = 2.0; + ui.style_mut().spacing.button_padding.y = 2.0; + let left = [SchemeSlot::Dk1, SchemeSlot::Lt1, SchemeSlot::Dk2, SchemeSlot::Lt2, SchemeSlot::Hlink, SchemeSlot::FolHlink]; + let right = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; + egui::Grid::new("theme-colors").spacing(vec2(18.0, 4.0)).show(ui, |ui| { + for (a, b) in left.into_iter().zip(right) { + theme_color_row(ui, d, a); + theme_color_row(ui, d, b); + ui.end_row(); + } + }); + ui.add_space(6.0); + let mut name = d.get("schemeName", ""); + ui.horizontal(|ui| { + ui.label("Name:"); + let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); + let shown = name.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name"); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert("schemeName".into(), name); + ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + let (ok, cancel) = buttons(ui, "Save"); + if ok { + match theme_color_payload(d) { + Ok(payload) => return app.run("design.colors", payload).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/theme_fonts.rs b/crates/ui-egui/src/dialogs/theme_fonts.rs new file mode 100644 index 000000000..0bf15ff06 --- /dev/null +++ b/crates/ui-egui/src/dialogs/theme_fonts.rs @@ -0,0 +1,114 @@ +use super::*; + +fn seed_theme_fonts(d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); + let major = d.params.get("major").and_then(Value::as_str).unwrap_or("Inter"); + let minor = d.params.get("minor").and_then(Value::as_str).unwrap_or(major); + d.fields.insert("schemeName".into(), name.to_string()); + d.fields.insert("major".into(), major.to_string()); + d.fields.insert("minor".into(), minor.to_string()); + d.fields.insert("seeded".into(), "1".into()); +} + +fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec { + let mut v = crate::ribbon::font_families(app); + for scheme in deckcraft_model::theme::builtin_font_schemes() { + v.push(scheme.major.latin); + v.push(scheme.minor.latin); + } + if !selected.is_empty() { + v.push(selected.to_string()); + } + let mut seen = std::collections::HashSet::new(); + v.retain(|face| !face.is_empty() && seen.insert(face.clone())); + v +} + +/// Draw an accessible font combo box and update the selected family; the caller supplies the +/// visible label. +fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) { + let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { + for face in choices { + if ui.selectable_label(selected == face, face).clicked() { + *selected = face.clone(); + } + } + }); + let shown = selected.clone(); + let access = label.to_string(); + resp.response.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone()); + info.current_text_value = Some(shown.clone()); + info + }); +} + +fn theme_font_payload(d: &mut Dialog) -> Result { + let name = d.get("schemeName", "").trim().to_string(); + if name.is_empty() { + return Err("Enter a scheme name.".into()); + } + let major = d.get("major", "").trim().to_string(); + if major.is_empty() { + return Err("Choose a heading font.".into()); + } + let minor = d.get("minor", "").trim().to_string(); + if minor.is_empty() { + return Err("Choose a body font.".into()); + } + Ok(json!({"name": name, "major": major, "minor": minor})) +} + +/// Create New Theme Fonts. Returns true when the dialog should close. +pub(super) fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_theme_fonts(d); + let major = d.get("major", "Inter"); + let minor = d.get("minor", "Inter"); + let headings = theme_font_choices(app, &major); + let bodies = theme_font_choices(app, &minor); + let mut major = major; + let mut minor = minor; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Heading:")); + font_combo(ui, "Heading font", &headings, &mut major); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Body:")); + font_combo(ui, "Body font", &bodies, &mut minor); + }); + d.fields.insert("major".into(), major.clone()); + d.fields.insert("minor".into(), minor.clone()); + ui.add_space(4.0); + // Theme faces are not egui families; naming an unbound family panics. The lines show the + // chosen names at heading and body sizes. + ui.label(egui::RichText::new(format!("Heading sample {major}")).size(20.0)); + ui.label(egui::RichText::new(format!("Body sample {minor}")).size(14.0)); + ui.add_space(4.0); + let mut name = d.get("schemeName", ""); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Name:")); + let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName")); + let shown = name.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name"); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert("schemeName".into(), name); + ui.label(egui::RichText::new("Heading and body fonts for this presentation.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + let (ok, cancel) = buttons(ui, "Save"); + if ok { + match theme_font_payload(d) { + Ok(payload) => return app.run("design.fonts", payload).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/trim.rs b/crates/ui-egui/src/dialogs/trim.rs new file mode 100644 index 000000000..294f2fc22 --- /dev/null +++ b/crates/ui-egui/src/dialogs/trim.rs @@ -0,0 +1,50 @@ +/// Seconds for a Trim Media field. Three decimal places, trailing zeros dropped, so the text +/// round-trips the millisecond value the clip stores. +pub(super) fn ms_as_seconds(ms: u32) -> String { + let s = format!("{:.3}", f64::from(ms) / 1000.0); + let trimmed = s.trim_end_matches('0').trim_end_matches('.'); + if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() } +} + +/// Parse a seconds field back to milliseconds. Blank, junk and negatives become 0; the result is +/// rounded so `0.333` is 333 ms, not 332. +pub(super) fn seconds_text_to_ms(text: &str) -> u32 { + let v = text.trim().parse::().ok().filter(|v| v.is_finite()).unwrap_or(0.0); + let ms = (v.max(0.0) * 1000.0).round(); + if ms >= f64::from(u32::MAX) { u32::MAX } else { ms as u32 } +} + +/// Start, end trim, fade in and fade out, as the Trim Media dialog shows them. +pub(super) struct TrimSeed { + pub(super) start: String, + pub(super) end: String, + pub(super) fade_in: String, + pub(super) fade_out: String, +} + +impl TrimSeed { + fn zeros() -> Self { + TrimSeed { start: "0".into(), end: "0".into(), fade_in: "0".into(), fade_out: "0".into() } + } + fn from_clip(m: &deckcraft_model::MediaClip) -> Self { + TrimSeed { + start: ms_as_seconds(m.trim_start_ms), + end: ms_as_seconds(m.trim_end_ms), + fade_in: ms_as_seconds(m.fade_in_ms), + fade_out: ms_as_seconds(m.fade_out_ms), + } + } +} + +/// The first selected audio or video. Other shapes in the selection are skipped. No media means +/// the old zeros, which OK then stores. +pub(super) fn trim_seed(st: Option<&deckcraft_engine::DocState>) -> TrimSeed { + let Some(st) = st else { return TrimSeed::zeros() }; + st.selected_shapes() + .into_iter() + .find_map(|sh| match &sh.kind { + deckcraft_model::ShapeKind::Media(m) => Some(TrimSeed::from_clip(m)), + _ => None, + }) + .unwrap_or_else(TrimSeed::zeros) +} diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs new file mode 100644 index 000000000..1149b6a7c --- /dev/null +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -0,0 +1,62 @@ +use super::*; + +/// Preset percents in the Zoom dialog, largest first. +const ZOOM_PRESETS: &[u32] = &[400, 200, 150, 100, 75, 66, 50, 33]; + +/// A typed zoom percent as a scale in `0.1..=4` (10%–400%). `None` when it is not a finite number. +pub fn parse_zoom_percent(text: &str) -> Option { + let t = text.trim().trim_end_matches('%').trim(); + if t.is_empty() { + return None; + } + let v = t.parse::().ok().filter(|n| n.is_finite())?; + Some(((v / 100.0) as f32).clamp(0.1, 4.0)) +} + +/// Zoom dialog: a percent or Fit. Nothing changes until OK. +pub(super) fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("mode") { + let shown = app.ui.zoom.unwrap_or(app.canvas_scale); + let pct = if shown.is_finite() { (shown * 100.0).round().clamp(10.0, 400.0) } else { 100.0 }; + d.fields.insert("mode".into(), if app.ui.zoom.is_none() { "fit".into() } else { "percent".into() }); + d.fields.insert("percent".into(), format!("{pct:.0}")); + } + let mut mode = d.get("mode", "percent"); + let mut percent = d.get("percent", "100"); + ui.label("Zoom to"); + if ui.radio(mode == "fit", "Fit").clicked() { + mode = "fit".into(); + } + for pct in ZOOM_PRESETS { + let on = mode == "percent" && percent == pct.to_string(); + if ui.radio(on, format!("{pct}%")).clicked() { + mode = "percent".into(); + percent = pct.to_string(); + } + } + ui.horizontal(|ui| { + let l = ui.label("Percent:"); + let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id); + if edit.changed() { + mode = "percent".into(); + } + ui.label("%"); + }); + d.fields.insert("mode".into(), mode.clone()); + d.fields.insert("percent".into(), percent.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + if mode == "fit" { + app.ui.zoom = None; + return true; + } + match parse_zoom_percent(&percent) { + Some(scale) => app.ui.zoom = Some(scale), + None => { + app.set_status("Enter a zoom percent"); + return false; + } + } + } + ok || cancel +} From 9545ff4d6164a528d1f215995084b316d97f9f3c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:35:11 +0000 Subject: [PATCH 42/90] refactor(ui-egui): move the zoom, trim, about and alt-text test modules to their dialog files --- crates/ui-egui/src/dialogs.rs | 145 ------------------------- crates/ui-egui/src/dialogs/about.rs | 56 ++++++++++ crates/ui-egui/src/dialogs/alt_text.rs | 14 +++ crates/ui-egui/src/dialogs/trim.rs | 47 ++++++++ crates/ui-egui/src/dialogs/zoom.rs | 22 ++++ 5 files changed, 139 insertions(+), 145 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7f25f2d4b..00ac33c76 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -225,11 +225,7 @@ mod theme_fonts; mod trim; mod zoom; -#[cfg(test)] -use about::{CREDITS_SIZE, credits_block}; pub use about::{about, paint_logo, version_line}; -#[cfg(test)] -use alt_text::alt_text_starts_decorative; use body::body; #[cfg(test)] use hyperlink::{ @@ -237,8 +233,6 @@ use hyperlink::{ }; pub use language::selected_language; use preferences::restore_preferences; -#[cfg(test)] -use trim::{ms_as_seconds, seconds_text_to_ms, trim_seed}; pub use zoom::parse_zoom_percent; fn seed_once(d: &mut Dialog, key: &str, val: String) { @@ -322,153 +316,14 @@ mod theme_colors_tests; #[cfg(test)] mod theme_fonts_tests; -#[cfg(test)] -mod alt_text_tests { - use super::alt_text_starts_decorative; - - #[test] - fn decorative_starts_checked_only_when_every_shape_is() { - assert!(!alt_text_starts_decorative([])); - assert!(!alt_text_starts_decorative([false])); - assert!(alt_text_starts_decorative([true])); - assert!(alt_text_starts_decorative([true, true])); - assert!(!alt_text_starts_decorative([true, false])); - } -} - #[cfg(test)] mod tests; -#[cfg(test)] -mod zoom_tests { - use super::parse_zoom_percent; - - fn near(v: f32, want: f32) -> bool { - (v - want).abs() < 1.0e-4 - } - - #[test] - fn zoom_percent_accepts_a_number_and_clamps() { - assert!(parse_zoom_percent("150").is_some_and(|v| near(v, 1.5))); - assert!(parse_zoom_percent("150%").is_some_and(|v| near(v, 1.5))); - assert!(parse_zoom_percent(" 100 ").is_some_and(|v| near(v, 1.0))); - assert!(parse_zoom_percent("1").is_some_and(|v| near(v, 0.1))); - assert!(parse_zoom_percent("900").is_some_and(|v| near(v, 4.0))); - assert!(parse_zoom_percent("").is_none()); - assert!(parse_zoom_percent("fit").is_none()); - assert!(parse_zoom_percent("nan").is_none()); - assert!(parse_zoom_percent("inf").is_none()); - } -} - -#[cfg(test)] -mod trim_media_tests { - use std::sync::Arc; - - use deckcraft_model::{MediaClip, Presentation, ShapeId, ShapeKind}; - - use super::{ms_as_seconds, seconds_text_to_ms, trim_seed}; - - #[test] - fn trim_seconds_round_trip_the_stored_milliseconds() { - for ms in [0, 1, 250, 333, 1000, 1500, 60_000] { - let text = ms_as_seconds(ms); - assert_eq!(seconds_text_to_ms(&text), ms, "{text}"); - } - assert_eq!(seconds_text_to_ms(""), 0); - assert_eq!(seconds_text_to_ms("nope"), 0); - assert_eq!(seconds_text_to_ms("-2"), 0); - assert_eq!(seconds_text_to_ms("1.5"), 1500); - assert_eq!(seconds_text_to_ms("999999999"), u32::MAX); - } - - #[test] - fn trim_dialog_reads_the_selected_clip() { - assert_eq!(trim_seed(None).start, "0"); - let mut doc = Presentation::default(); - let media_id = { - let slide = Arc::make_mut(&mut doc.slides[0]); - let title = slide.shapes[0].id; - let clip = MediaClip { trim_start_ms: 1500, trim_end_ms: 250, fade_in_ms: 333, fade_out_ms: 1000, ..Default::default() }; - let mut media = slide.shapes[0].clone(); - media.id = ShapeId(900); - media.kind = ShapeKind::Media(clip); - slide.shapes.push(media); - (title, ShapeId(900)) - }; - let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); - st.selection.shapes = vec![media_id.0]; - assert_eq!(trim_seed(Some(&st)).start, "0", "a title shape is not a clip"); - st.selection.shapes = vec![media_id.0, media_id.1]; - let seed = trim_seed(Some(&st)); - assert_eq!(seed.start, "1.5"); - assert_eq!(seed.end, "0.25"); - assert_eq!(seed.fade_in, "0.333"); - assert_eq!(seed.fade_out, "1"); - } -} - #[cfg(test)] mod spelling_tests; #[cfg(test)] mod paragraph_dialog_tests; -#[cfg(test)] -mod about_credits_tests { - use egui_kittest::Harness; - use egui_kittest::kittest::Queryable; - - use super::{CREDITS_SIZE, Dialog, credits_block}; - use crate::{Services, SlideApp}; - - fn tab_of(h: &Harness<'_, SlideApp>) -> String { - let d = h.state().dialog.as_ref().expect("the About dialog is open"); - d.fields.get("tab").cloned().unwrap_or_else(|| "about".into()) - } - - /// The reported symptom (#843): in the real dialog the tabs stopped answering once Contributors - /// or Models was open. Click through Contributors, Models and back to About. - #[test] - fn about_dialog_tabs_keep_responding() { - let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), Services::default()); - app.dialog = Some(Dialog::new("about")); - let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.run_steps(4); - assert_eq!(tab_of(&h), "about"); - for label in ["Contributors", "Models", "About", "Models", "Contributors"] { - h.get_by_label(label).click(); - h.run_steps(3); - assert_eq!(tab_of(&h), label.to_lowercase(), "clicking {label} switches the tab"); - } - } - - #[test] - fn credits_get_their_own_block_below_the_tab_row() { - for models in [false, true] { - let ctx = egui::Context::default(); - let mut tabs = egui::Rect::NOTHING; - let mut block = egui::Rect::NOTHING; - let mut whole = egui::Rect::NOTHING; - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - tabs = ui.label("About").rect; - block = credits_block(ui, models); - whole = ui.min_rect(); - }); - out.textures_delta.clear(); - assert_eq!(block.size(), CREDITS_SIZE, "models={models}"); - assert!(block.min.y >= tabs.max.y, "the credits start below the tab row (models={models})"); - assert!(whole.max.y >= block.max.y, "the dialog grows to hold the block (models={models})"); - assert!(whole.height() > CREDITS_SIZE.y, "the tab row is not squeezed into the credits height (models={models})"); - } - } -} - #[cfg(test)] mod preferences_tests; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index 00f4433ca..a5f80aec3 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -64,3 +64,59 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +#[cfg(test)] +mod about_credits_tests { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + use super::{CREDITS_SIZE, Dialog, credits_block}; + use crate::{Services, SlideApp}; + + fn tab_of(h: &Harness<'_, SlideApp>) -> String { + let d = h.state().dialog.as_ref().expect("the About dialog is open"); + d.fields.get("tab").cloned().unwrap_or_else(|| "about".into()) + } + + /// The reported symptom (#843): in the real dialog the tabs stopped answering once Contributors + /// or Models was open. Click through Contributors, Models and back to About. + #[test] + fn about_dialog_tabs_keep_responding() { + let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), Services::default()); + app.dialog = Some(Dialog::new("about")); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert_eq!(tab_of(&h), "about"); + for label in ["Contributors", "Models", "About", "Models", "Contributors"] { + h.get_by_label(label).click(); + h.run_steps(3); + assert_eq!(tab_of(&h), label.to_lowercase(), "clicking {label} switches the tab"); + } + } + + #[test] + fn credits_get_their_own_block_below_the_tab_row() { + for models in [false, true] { + let ctx = egui::Context::default(); + let mut tabs = egui::Rect::NOTHING; + let mut block = egui::Rect::NOTHING; + let mut whole = egui::Rect::NOTHING; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + tabs = ui.label("About").rect; + block = credits_block(ui, models); + whole = ui.min_rect(); + }); + out.textures_delta.clear(); + assert_eq!(block.size(), CREDITS_SIZE, "models={models}"); + assert!(block.min.y >= tabs.max.y, "the credits start below the tab row (models={models})"); + assert!(whole.max.y >= block.max.y, "the dialog grows to hold the block (models={models})"); + assert!(whole.height() > CREDITS_SIZE.y, "the tab row is not squeezed into the credits height (models={models})"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/alt_text.rs b/crates/ui-egui/src/dialogs/alt_text.rs index 303b676c5..091f958cb 100644 --- a/crates/ui-egui/src/dialogs/alt_text.rs +++ b/crates/ui-egui/src/dialogs/alt_text.rs @@ -9,3 +9,17 @@ pub(super) fn alt_text_starts_decorative(decorative: impl IntoIterator) -> TrimSeed { }) .unwrap_or_else(TrimSeed::zeros) } + +#[cfg(test)] +mod trim_media_tests { + use std::sync::Arc; + + use deckcraft_model::{MediaClip, Presentation, ShapeId, ShapeKind}; + + use super::{ms_as_seconds, seconds_text_to_ms, trim_seed}; + + #[test] + fn trim_seconds_round_trip_the_stored_milliseconds() { + for ms in [0, 1, 250, 333, 1000, 1500, 60_000] { + let text = ms_as_seconds(ms); + assert_eq!(seconds_text_to_ms(&text), ms, "{text}"); + } + assert_eq!(seconds_text_to_ms(""), 0); + assert_eq!(seconds_text_to_ms("nope"), 0); + assert_eq!(seconds_text_to_ms("-2"), 0); + assert_eq!(seconds_text_to_ms("1.5"), 1500); + assert_eq!(seconds_text_to_ms("999999999"), u32::MAX); + } + + #[test] + fn trim_dialog_reads_the_selected_clip() { + assert_eq!(trim_seed(None).start, "0"); + let mut doc = Presentation::default(); + let media_id = { + let slide = Arc::make_mut(&mut doc.slides[0]); + let title = slide.shapes[0].id; + let clip = MediaClip { trim_start_ms: 1500, trim_end_ms: 250, fade_in_ms: 333, fade_out_ms: 1000, ..Default::default() }; + let mut media = slide.shapes[0].clone(); + media.id = ShapeId(900); + media.kind = ShapeKind::Media(clip); + slide.shapes.push(media); + (title, ShapeId(900)) + }; + let mut st = deckcraft_engine::DocState::new(doc, None, "t".into()); + st.selection.shapes = vec![media_id.0]; + assert_eq!(trim_seed(Some(&st)).start, "0", "a title shape is not a clip"); + st.selection.shapes = vec![media_id.0, media_id.1]; + let seed = trim_seed(Some(&st)); + assert_eq!(seed.start, "1.5"); + assert_eq!(seed.end, "0.25"); + assert_eq!(seed.fade_in, "0.333"); + assert_eq!(seed.fade_out, "1"); + } +} diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs index 1149b6a7c..9bf392bfc 100644 --- a/crates/ui-egui/src/dialogs/zoom.rs +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -60,3 +60,25 @@ pub(super) fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } ok || cancel } + +#[cfg(test)] +mod zoom_tests { + use super::parse_zoom_percent; + + fn near(v: f32, want: f32) -> bool { + (v - want).abs() < 1.0e-4 + } + + #[test] + fn zoom_percent_accepts_a_number_and_clamps() { + assert!(parse_zoom_percent("150").is_some_and(|v| near(v, 1.5))); + assert!(parse_zoom_percent("150%").is_some_and(|v| near(v, 1.5))); + assert!(parse_zoom_percent(" 100 ").is_some_and(|v| near(v, 1.0))); + assert!(parse_zoom_percent("1").is_some_and(|v| near(v, 0.1))); + assert!(parse_zoom_percent("900").is_some_and(|v| near(v, 4.0))); + assert!(parse_zoom_percent("").is_none()); + assert!(parse_zoom_percent("fit").is_none()); + assert!(parse_zoom_percent("nan").is_none()); + assert!(parse_zoom_percent("inf").is_none()); + } +} From e84ba9def1e38cdbbd736eb95ec9f5fdcf8cb6e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:41:06 +0000 Subject: [PATCH 43/90] refactor(ui-egui): split ribbon.rs into one file per tab under ribbon/ --- crates/ui-egui/src/ribbon.rs | 3461 +------------------ crates/ui-egui/src/ribbon/animations.rs | 218 ++ crates/ui-egui/src/ribbon/arrange.rs | 158 + crates/ui-egui/src/ribbon/chart.rs | 25 + crates/ui-egui/src/ribbon/design.rs | 170 + crates/ui-egui/src/ribbon/draw.rs | 45 + crates/ui-egui/src/ribbon/galleries.rs | 391 +++ crates/ui-egui/src/ribbon/home.rs | 471 +++ crates/ui-egui/src/ribbon/insert.rs | 271 ++ crates/ui-egui/src/ribbon/masters.rs | 141 + crates/ui-egui/src/ribbon/picture_format.rs | 278 ++ crates/ui-egui/src/ribbon/playback.rs | 55 + crates/ui-egui/src/ribbon/review.rs | 58 + crates/ui-egui/src/ribbon/shape_format.rs | 266 ++ crates/ui-egui/src/ribbon/slide_show.rs | 78 + crates/ui-egui/src/ribbon/smartart.rs | 103 + crates/ui-egui/src/ribbon/table.rs | 236 ++ crates/ui-egui/src/ribbon/title_bar.rs | 95 + crates/ui-egui/src/ribbon/transitions.rs | 239 ++ crates/ui-egui/src/ribbon/view.rs | 155 + 20 files changed, 3503 insertions(+), 3411 deletions(-) create mode 100644 crates/ui-egui/src/ribbon/animations.rs create mode 100644 crates/ui-egui/src/ribbon/arrange.rs create mode 100644 crates/ui-egui/src/ribbon/chart.rs create mode 100644 crates/ui-egui/src/ribbon/design.rs create mode 100644 crates/ui-egui/src/ribbon/draw.rs create mode 100644 crates/ui-egui/src/ribbon/galleries.rs create mode 100644 crates/ui-egui/src/ribbon/home.rs create mode 100644 crates/ui-egui/src/ribbon/insert.rs create mode 100644 crates/ui-egui/src/ribbon/masters.rs create mode 100644 crates/ui-egui/src/ribbon/picture_format.rs create mode 100644 crates/ui-egui/src/ribbon/playback.rs create mode 100644 crates/ui-egui/src/ribbon/review.rs create mode 100644 crates/ui-egui/src/ribbon/shape_format.rs create mode 100644 crates/ui-egui/src/ribbon/slide_show.rs create mode 100644 crates/ui-egui/src/ribbon/smartart.rs create mode 100644 crates/ui-egui/src/ribbon/table.rs create mode 100644 crates/ui-egui/src/ribbon/title_bar.rs create mode 100644 crates/ui-egui/src/ribbon/transitions.rs create mode 100644 crates/ui-egui/src/ribbon/view.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 374457777..51c46b6c7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -12,29 +12,57 @@ use crate::widgets::{ self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex, small_button_named, }; -pub const TITLE_H: f32 = 38.0; +mod animations; +mod arrange; +mod chart; +mod design; +mod draw; +mod galleries; +mod home; +mod insert; +mod masters; +mod picture_format; +mod playback; +mod review; +mod shape_format; +mod slide_show; +mod smartart; +mod table; +mod title_bar; +mod transitions; +mod view; -/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the -/// traffic lights itself, 16 pt from the top of the window on macOS 27. Moving them would take -/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is the band they are centred -/// in, with the controls beside them (#71). Up to macOS 15 the lights are smaller and centred 14 pt -/// from the top, about 2 pt above this bar's centre. -pub const MAC_TITLE_BAR: f32 = 32.0; -/// The traffic lights as AppKit lays them out, measured on macOS 27: button diameter, -/// centre-to-centre pitch and the first button's left edge. -const MAC_LIGHT: f32 = 14.0; -const MAC_LIGHT_PITCH: f32 = 23.0; -const MAC_LIGHTS_LEFT: f32 = 9.0; -/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights -/// keep from the top of the window. -pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; - -/// The title bar's height, the left edge of its content and the height of its tallest control. -/// With the integrated title bar (macOS) the bar is shorter, so the controls shrink to keep a -/// margin above and below them. -fn title_geometry(integrated: bool) -> (f32, f32, f32) { - if integrated { (MAC_TITLE_BAR, MAC_CONTENT_LEFT, 22.0) } else { (TITLE_H, 10.0, 26.0) } -} +use animations::animations; +pub use animations::paint_anim_tile; +#[cfg(test)] +use animations::{animation_effect_rows, marked_options, text_build_id}; +pub use arrange::{align_menu, arrange_menu}; +use chart::chart_design; +use design::design; +pub use design::paint_theme_tile; +use draw::draw; +pub use galleries::{cref_param, effects_menu, outline_menu, paint_preset, shapes_gallery}; +pub use home::font_families; +use home::home; +use insert::insert; +pub use insert::table_grid_picker; +use masters::{handout_master, notes_master, slide_master}; +use picture_format::picture_format; +use playback::playback; +use review::review; +use shape_format::shape_format; +use slide_show::{record, slide_show}; +use smartart::smartart_design; +pub use table::paint_table_style; +use table::{table_design, table_layout}; +pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_H, title_bar}; +#[cfg(test)] +use title_bar::{MAC_LIGHT, MAC_LIGHT_PITCH, MAC_LIGHTS_LEFT, title_geometry}; +pub use transitions::paint_transition_tile; +#[cfg(test)] +use transitions::transition_effect_rows; +use transitions::transitions; +use view::view; /// Is engine command `id` enabled right now? /// Whether `id` names an engine or UI command. @@ -53,72 +81,6 @@ fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } -/// The window's title row: traffic-light space, quick access, centred title, actions. -pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let (height, left, control_h) = title_geometry(app.integrated_titlebar); - egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { - let rect = ui.max_rect(); - // Dragging the empty title bar moves the window. - let drag = ui.interact(rect, ui.scope_id().with("titledrag"), Sense::click_and_drag()); - // The drag surface is not a button: a full-width Button would be announced as one. - drag.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Window, true, "Window title bar")); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(rect.min.x + left, rect.min.y), pos2(rect.min.x + 420.0, rect.max.y))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - child.spacing_mut().item_spacing.x = 2.0; - let has_doc = app.session.active().is_some(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - crate::qat::show(app, &mut child); - let title = - app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); - // Right side. - let mut right = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(rect.max.x - 320.0, rect.min.y), pos2(rect.max.x - 10.0, rect.max.y))) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - right.spacing_mut().item_spacing.x = 6.0; - if icon_toggle(&mut right, Icon::Search, "Search commands (⇧⌘P)", true, false).clicked() { - app.palette = Some((String::new(), 0)); - } - let share = right.add( - egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) - .fill(t.accent) - .corner_radius(control_h / 2.0) - .min_size(vec2(84.0, control_h)), - ); - if share.clicked() && has_doc { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, false); - } - if icon_toggle(&mut right, Icon::CommentsBubble, "Comments", has_doc, app.ui.pane.as_deref() == Some("comments")).clicked() { - let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); - } - if icon_toggle( - &mut right, - if app.ui.brightness == theme::Brightness::Dark { Icon::Sparkle } else { Icon::Palette }, - "Light/Dark appearance", - true, - false, - ) - .clicked() - { - let _ = app.run("view.dark", json!({})); - } - }); -} - /// Which contextual tabs apply to the selection. fn contextual(app: &SlideApp) -> Vec<&'static str> { let mut v = vec![]; @@ -327,27 +289,6 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va crate::qat::ribbon_menu(app, &r, icon, label, id, &p); } -/// Photo Album is a split button: the main click starts a new album, the arrow edits one. -fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { - let en = enabled(app, "insert.photoAlbum"); - let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); - let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); - main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); - if let Some(a) = &arrow { - let _ = a.clone().on_hover_text("Photo Album options"); - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); - } - if main.clicked() { - run(app, "insert.photoAlbum", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).show(|ui| { - menu_item(app, ui, Icon::Picture, "New Photo Album…", "insert.photoAlbum", json!({})); - menu_item(app, ui, Icon::Picture, "Edit Photo Album…", "insert.photoAlbumEdit", json!({})); - }); - } -} - fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); let r = small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)); @@ -392,890 +333,6 @@ pub fn fmt_state(app: &mut SlideApp) -> Value { app.session.execute("format.state", &json!({})).unwrap_or_default() } -// ---------- Home ---------- - -fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false); - if main.on_hover_text("Paste (⌘V)").clicked() { - run(app, "edit.paste", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).show(|ui| { - if ui.button("Paste").clicked() { - run(app, "edit.paste", json!({})); - } - if ui.button("Paste and Match Formatting").clicked() { - run(app, "edit.pasteText", json!({})); - } - if ui.button("Paste Special…").clicked() { - run(app, "app.pasteSpecial", json!({})); - } - if ui.button("Paste Formatting").clicked() { - run(app, "format.painterApply", json!({})); - } - }); - } - rows(ui, |ui| { - small(app, ui, Icon::Cut, "Cut", "edit.cut", json!({})); - small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); - let on = app.session.painter.is_some(); - let en = enabled(app, "format.painter"); - let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); - if r.double_clicked() { - run(app, "format.painter", json!({"sticky": true})); - } else if r.clicked() { - if on { - app.session.painter = None; - } else { - run(app, "format.painter", json!({})); - } - } - }); - }); -} - -fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { - let layouts: Vec<(String, deckcraft_model::LayoutId)> = app - .session - .active() - .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id)).collect())) - .unwrap_or_default(); - egui::Grid::new(("layouts", cmd)).spacing(vec2(6.0, 6.0)).show(ui, |ui| { - for (k, (name, id)) in layouts.iter().enumerate() { - let idx = app.session.active().and_then(|d| d.doc.masters.first().and_then(|m| m.layouts.iter().position(|l| l.id == *id))); - let resp = ui.vertical(|ui| { - let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 54.0), Sense::click()); - if let (Some(d), Some(li)) = (app.session.active(), idx) { - let img = deckcraft_render::render_layout( - &d.doc, - 0, - Some(li), - &deckcraft_render::RenderOpts { - scale: 192.0 / d.doc.slide_size.width.max(1.0), - edit: true, - size: Some((192, 108)), - ..Default::default() - }, - ); - let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); - let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); - ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke( - rect, - CornerRadius::ZERO, - Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), - egui::StrokeKind::Outside, - ); - ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5)).truncate()); - resp - }); - if resp.inner.clicked() { - run(app, cmd, json!({"layout": id.0})); - ui.close(); - } - if k % 4 == 3 { - ui.end_row(); - } - } - }); -} - -/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls. -fn slides_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); - if main.on_hover_text("New Slide (⇧⌘N)").clicked() { - run(app, "slide.new", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).width(420.0).show(|ui| new_slide_menu(app, ui)); - } - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| layout_menu(app, ui, "slide.layout")); - } - small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); - let (main, arrow) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| section_menu(app, ui)); - } - }); - }); -} - -/// The New Slide button's menu: layouts, duplication, outline import and slide reuse. -fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { - layout_menu(app, ui, "slide.new"); - ui.separator(); - if ui.button("Duplicate Selected Slides").clicked() { - run(app, "slide.duplicate", json!({})); - } - if ui.button("Slides from Outline…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("outline")); - } - let desktop = app.services.pick_open.is_some(); - for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { - let r = ui.add_enabled(desktop, egui::Button::new(label)); - if r.clicked() { - run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); - } - r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); - } -} - -/// The Section split button's menu. -fn section_menu(app: &mut SlideApp, ui: &mut Ui) { - if ui.button("Add Section").clicked() { - run(app, "section.add", json!({})); - } - if ui.button("Remove All Sections").clicked() { - run(app, "section.removeAll", json!({})); - } -} - -pub fn font_families(app: &SlideApp) -> Vec { - let mut v = vec![]; - if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) { - v.push(m.theme.fonts.major.latin.clone()); - v.push(m.theme.fonts.minor.latin.clone()); - } - v.extend(deckcraft_fonts::FontDb::global().families()); - v.dedup(); - v -} - -fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { - let en = enabled(app, "format.bold"); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); - let size = st.get("size").and_then(Value::as_f64); - let fams = font_families(app); - let theme_fonts: Vec = fams.iter().take(2).cloned().collect(); - dropdown(ui, "font", &font, 150.0, en, |ui| { - for (k, f) in fams.iter().enumerate() { - let label = if k == 0 && theme_fonts.len() == 2 { - format!("{f} (Headings)") - } else if k == 1 && theme_fonts.len() == 2 { - format!("{f} (Body)") - } else { - f.clone() - }; - if ui.button(egui::RichText::new(label)).clicked() { - run(app, "format.font", json!({"family": f})); - ui.close(); - } - if k == 1 { - ui.separator(); - } - } - ui.separator(); - // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). - if ui.button("Rescan Installed Fonts").clicked() { - run(app, "font.rescan", json!({})); - ui.close(); - } - }); - let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); - dropdown(ui, "size", &size_text, 52.0, en, |ui| { - for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) { - if ui.button(widgets::fmt_num(*s, "")).clicked() { - run(app, "format.size", json!({"size": s})); - ui.close(); - } - } - }); - toggle(app, ui, Icon::FontGrow, "Increase Font Size", "format.grow", false); - toggle(app, ui, Icon::FontShrink, "Decrease Font Size", "format.shrink", false); - toggle(app, ui, Icon::ClearFormatting, "Clear All Formatting", "format.clear", false); - }); - ui.add_space(3.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - toggle(app, ui, Icon::Bold, "Bold", "format.bold", b("bold")); - toggle(app, ui, Icon::Italic, "Italic", "format.italic", b("italic")); - toggle(app, ui, Icon::Underline, "Underline", "format.underline", b("underline")); - toggle(app, ui, Icon::Strikethrough, "Strikethrough", "format.strikethrough", b("strike")); - toggle(app, ui, Icon::Superscript, "Superscript", "format.superscript", b("superscript")); - toggle(app, ui, Icon::Subscript, "Subscript", "format.subscript", b("subscript")); - let r = icon_toggle(ui, Icon::CharSpacing, "Character Spacing", en, false); - egui::Popup::menu(&r).show(|ui| { - for (label, pt) in [("Very Tight", -3.0), ("Tight", -1.5), ("Normal", 0.0), ("Loose", 3.0), ("Very Loose", 6.0)] { - if ui.button(label).clicked() { - run(app, "format.spacing", json!({"pt": pt})); - } - } - }); - let r = icon_toggle(ui, Icon::ChangeCase, "Change Case", en, false); - egui::Popup::menu(&r).show(|ui| { - for (label, mode) in [ - ("Sentence case.", "sentence"), - ("lowercase", "lower"), - ("UPPERCASE", "upper"), - ("Capitalize Each Word", "title"), - ("tOGGLE cASE", "toggle"), - ] { - if ui.button(label).clicked() { - run(app, "format.case", json!({"mode": mode})); - } - } - }); - let sc = scheme(app); - let (m, a) = color_split(ui, Icon::Highlight, "Text Highlight Color", Color32::from_rgb(255, 230, 0), en); - if m.clicked() { - run(app, "format.highlight", json!({"color": "#FFFF00"})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) { - run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())})); - } - }); - let (m, a) = color_split(ui, Icon::FontColor, "Font Color", Color32::from_rgb(0xD8, 0x3F, 0x3F), en); - if m.clicked() { - run(app, "format.color", json!({"color": "#C00000"})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - run(app, "format.color", json!({"color": cref_param(&c)})); - } - }); - if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { - run(app, "format.fontDialog", json!({})); - } - }); - }); - }); -} - -/// A ColorRef as a command colour parameter. -pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { - match &c.base { - deckcraft_model::ColorBase::Scheme { slot } => { - let mut o = serde_json::Map::new(); - o.insert("scheme".into(), json!(slot.xml_name())); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - deckcraft_model::ColorBase::Rgb { rgb } if c.mods.is_empty() => json!(format!("#{}", rgb.hex())), - deckcraft_model::ColorBase::Rgb { rgb } => { - let mut o = serde_json::Map::new(); - o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - deckcraft_model::ColorBase::Preset { name } => { - let mut o = serde_json::Map::new(); - o.insert("preset".into(), json!(name)); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - deckcraft_model::ColorBase::System { name, last } => { - let mut o = serde_json::Map::new(); - o.insert("system".into(), json!(name)); - o.insert("last".into(), json!(format!("#{}", last.hex()))); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - } -} - -/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected -/// text. -/// A layout gallery grouped by category, each layout as a small cached picture with its name. -/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn -/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the -/// document. -fn smartart_gallery( - app: &mut SlideApp, - ui: &mut Ui, - cmd: &str, - extra: Value, - current: &str, - style: &str, - colors: &str, - nodes: Option<&[deckcraft_model::DiagramNode]>, -) { - use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; - ui.set_min_width(440.0); - let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { - for (cat, title) in [ - ("list", "List"), - ("process", "Process"), - ("cycle", "Cycle"), - ("hierarchy", "Hierarchy"), - ("relationship", "Relationship"), - ("matrix", "Matrix"), - ("pyramid", "Pyramid"), - ("picture", "Picture"), - ] { - let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); - if kinds.is_empty() { - continue; - } - ui.label(egui::RichText::new(title).strong()); - egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { - for (i, (k, label)) in kinds.iter().enumerate() { - let ctx = ui.ctx().clone(); - let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); - let r = ui - .vertical_centered(|ui| { - ui.set_width(100.0); - let r = match &thumb { - Some(t) => { - ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) - } - None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), - }; - ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); - r.on_hover_text(*label) - }) - .inner; - if r.hovered() { - let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480)); - if let Some(t) = preview { - r.show_tooltip_ui(|ui| { - ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0))); - }); - } - } - if r.clicked() { - let mut args = extra.clone(); - args["kind"] = json!(k); - run(app, cmd, args); - ui.close(); - } - if i % 4 == 3 { - ui.end_row(); - } - } - }); - ui.separator(); - } - }); -} - -/// Render paragraph formatting controls using the current paragraph state. -fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { - let en = enabled(app, "format.bullets"); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); - if m.on_hover_text("Bullets").clicked() { - run(app, "format.bullets", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { - run(app, "format.bullets", json!({"on": false})); - } - for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { - if ui.button(egui::RichText::new(format!("{ch} Bullet")).size(14.0)).clicked() { - run(app, "format.bullets", json!({"on": true, "char": ch})); - } - } - ui.separator(); - if ui.button("Bullets and Numbering…").clicked() { - let mut dlg = crate::dialogs::Dialog::new("bullets"); - dlg.fields.insert("tab".into(), "bulleted".into()); - app.dialog = Some(dlg); - } - }); - } - let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); - if m.on_hover_text("Numbering").clicked() { - run(app, "format.numbering", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { - run(app, "format.numbering", json!({"on": false})); - } - for (label, sch) in [ - ("1. 2. 3.", "arabicPeriod"), - ("1) 2) 3)", "arabicParenR"), - ("I. II. III.", "romanUcPeriod"), - ("i. ii. iii.", "romanLcPeriod"), - ("A. B. C.", "alphaUcPeriod"), - ("a) b) c)", "alphaLcParenR"), - ("a. b. c.", "alphaLcPeriod"), - ] { - if ui.button(label).clicked() { - run(app, "format.numbering", json!({"on": true, "scheme": sch})); - } - } - ui.separator(); - if ui.button("Bullets and Numbering…").clicked() { - let mut dlg = crate::dialogs::Dialog::new("bullets"); - dlg.fields.insert("tab".into(), "numbered".into()); - app.dialog = Some(dlg); - } - }); - } - toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); - toggle(app, ui, Icon::IncreaseIndent, "Increase List Level", "format.indent", false); - let r = icon_toggle(ui, Icon::LineSpacing, "Line Spacing", en, false); - egui::Popup::menu(&r).show(|ui| { - for v in [1.0, 1.5, 2.0, 2.5, 3.0] { - if ui.button(format!("{v:.1}")).clicked() { - run(app, "format.lineSpacing", json!({"lines": v})); - } - } - if ui.button("Line Spacing Options…").clicked() { - run(app, "format.paragraphDialog", json!({})); - } - if ui.button("Tabs…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("tabs")); - } - }); - let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); - egui::Popup::menu(&r).show(|ui| { - for n in 1..=3 { - if ui.button(format!("{n} Column{}", if n > 1 { "s" } else { "" })).clicked() { - run(app, "format.columns", json!({"count": n, "spacing": 18})); - } - } - if ui.button("More Columns…").clicked() { - run(app, "format.columns", json!({})); - } - }); - }); - ui.add_space(3.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let al = st.get("align").and_then(Value::as_str).unwrap_or(""); - toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", al == "l"); - toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", al == "ctr"); - toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", al == "r"); - toggle(app, ui, Icon::Justify, "Justify", "format.justify", al == "just"); - let r = icon_toggle(ui, Icon::TextDirection, "Text Direction", en, false); - egui::Popup::menu(&r).show(|ui| { - for (l, d) in [ - ("Horizontal", "horizontal"), - ("Rotate all text 90°", "rotate90"), - ("Rotate all text 270°", "rotate270"), - ("Stacked", "stacked"), - ] { - if ui.button(l).clicked() { - run(app, "format.direction", json!({"dir": d})); - } - } - }); - let r = icon_toggle(ui, Icon::AlignText, "Align Text", en, false); - egui::Popup::menu(&r).show(|ui| { - for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { - if ui.button(l).clicked() { - run(app, "format.anchor", json!({"anchor": a})); - } - } - }); - let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); - egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); - if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { - run(app, "format.paragraphDialog", json!({})); - } - }); - }); - }); -} - -/// Shapes gallery popup body: every preset by category. Returns the chosen preset. -pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { - let t = Tokens::get(ui.ctx()); - let mut out = None; - ui.set_max_width(330.0); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for cat in deckcraft_geom::preset::Category::ALL { - ui.label(egui::RichText::new(cat.label()).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(1.0, 1.0); - for p in deckcraft_geom::preset::CATALOG.iter().filter(|p| p.category == cat) { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); - } - paint_preset(ui.painter(), r.shrink(4.0), p.name, Color32::TRANSPARENT, t.text); - if resp.on_hover_text(p.label).clicked() { - out = Some(p.name); - } - } - if cat == deckcraft_geom::preset::Category::Lines { - for (name, label) in deckcraft_engine::tools::FREEFORM_TOOLS { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); - } - paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text); - if resp.on_hover_text(label).clicked() { - out = Some(name); - } - } - } - }); - ui.add_space(4.0); - } - }); - if out.is_some() { - ui.close(); - } - out -} - -/// Draw a preset's outline (gallery icons). -pub fn paint_preset(p: &egui::Painter, r: Rect, name: &str, fill: Color32, stroke: Color32) { - let (w, h) = (r.width() as f64, r.height() as f64); - let Some(g) = deckcraft_geom::preset::build(name, w, h, &[]) else { return }; - for sp in &g.paths { - let mut pts: Vec = vec![]; - let mut polys: Vec<(Vec, bool)> = vec![]; - kurbo_flatten(&sp.path, |el| match el { - Fl::Move(x, y) => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), false)); - } - pts.clear(); - pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); - } - Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), - Fl::Close => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), true)); - } - } - }); - if pts.len() > 1 { - polys.push((pts, false)); - } - for (poly, closed) in polys { - if closed && fill != Color32::TRANSPARENT && sp.fill != deckcraft_geom::preset::FillMode::None { - p.add(egui::Shape::Path(egui::epaint::PathShape { points: poly.clone(), closed: true, fill, stroke: Stroke::NONE.into() })); - } - if sp.stroke { - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: poly, - closed, - fill: Color32::TRANSPARENT, - stroke: Stroke::new(1.0, stroke).into(), - })); - } - } - } -} - -enum Fl { - Move(f64, f64), - Line(f64, f64), - Close, -} - -fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { - deckcraft_geom::preset_flatten(path, &mut |el| match el { - deckcraft_geom::PathEl::MoveTo(p) => f(Fl::Move(p.x, p.y)), - deckcraft_geom::PathEl::LineTo(p) => f(Fl::Line(p.x, p.y)), - deckcraft_geom::PathEl::ClosePath => f(Fl::Close), - _ => {} - }); -} - -/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline. -fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let en = enabled(app, "shape.insert"); - let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes"); - egui::Popup::menu(&r).show(|ui| { - if let Some(p) = shapes_gallery(ui) { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); - } - }); - big(app, ui, Icon::TextBox, "Text Box", "insert.textBox", json!({})); - let sel = enabled(app, "shape.fill"); - let r = big_button(ui, Icon::Arrange, "Arrange", sel); - egui::Popup::menu(&r).show(|ui| arrange_menu(app, ui)); - let r = big_button(ui, Icon::QuickStyles, "Quick\nStyles", sel); - egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); - rows(ui, |ui| { - let sc = scheme(app); - let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); - if m.clicked() { - let c = app.last_fill.clone(); - run(app, "shape.fill", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { - match c { - Some(c) => { - app.last_fill = cref_param(&c); - run(app, "shape.fill", json!({"color": cref_param(&c)})); - } - None => run(app, "shape.fill", json!({"none": true})), - } - } - }); - } - let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); - if m.clicked() { - let c = app.last_outline.clone(); - run(app, "shape.line", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); - } - let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); - } - }); - }); -} - -pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::ColorScheme) { - if let Some(c) = widgets::color_grid(ui, sc, Some("No Outline")) { - match c { - Some(c) => { - app.last_outline = cref_param(&c); - run(app, "shape.line", json!({"color": cref_param(&c)})); - } - None => run(app, "shape.line", json!({"none": true})), - } - } - ui.menu_button("Weight", |ui| { - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - if ui.button(format!("{w} pt")).clicked() { - run(app, "shape.line", json!({"width": w})); - ui.close(); - } - } - }); - ui.menu_button("Dashes", |ui| { - for (l, d) in [ - ("Solid", "solid"), - ("Round Dot", "sysDot"), - ("Square Dot", "sysDash"), - ("Dash", "dash"), - ("Dash Dot", "dashDot"), - ("Long Dash", "lgDash"), - ("Long Dash Dot", "lgDashDot"), - ] { - if ui.button(l).clicked() { - run(app, "shape.line", json!({"dash": d})); - ui.close(); - } - } - }); - ui.menu_button("Arrows", |ui| { - for (l, h, tl) in [ - ("None", "none", "none"), - ("Arrow at end", "none", "triangle"), - ("Arrow at start", "triangle", "none"), - ("Arrows at both ends", "triangle", "triangle"), - ("Stealth end", "none", "stealth"), - ("Oval ends", "oval", "oval"), - ] { - if ui.button(l).clicked() { - run(app, "shape.line", json!({"head": h, "tail": tl})); - ui.close(); - } - } - }); -} - -pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.menu_button("Shadow", |ui| { - for (l, v) in [ - ("No Shadow", "none"), - ("Outer", "outer"), - ("Bottom", "bottom"), - ("Centered", "center"), - ("Inner", "inner"), - ("Perspective", "perspective"), - ] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"shadow": v})); - } - } - }); - ui.menu_button("Reflection", |ui| { - for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"reflection": v})); - } - } - }); - ui.menu_button("Glow", |ui| { - if ui.button("No Glow").clicked() { - run(app, "shape.effects", json!({"glow": null})); - } - for r in [5.0, 8.0, 11.0, 18.0] { - if ui.button(format!("{r} pt glow")).clicked() { - run(app, "shape.effects", json!({"glow": r})); - } - } - }); - ui.menu_button("Soft Edges", |ui| { - for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { - if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { - run(app, "shape.effects", json!({"softEdges": r})); - } - } - }); - ui.menu_button("Bevel", |ui| { - for b in [ - "none", - "circle", - "relaxedInset", - "cross", - "coolSlant", - "angle", - "softRound", - "convex", - "slope", - "divot", - "riblet", - "hardEdge", - "artDeco", - ] { - if ui.button(b).clicked() { - run(app, "shape.effects", json!({"bevel": b})); - } - } - }); - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); - } -} - -fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { - let sc = scheme(app); - let slots = [ - deckcraft_color::SchemeSlot::Dk1, - deckcraft_color::SchemeSlot::Accent1, - deckcraft_color::SchemeSlot::Accent2, - deckcraft_color::SchemeSlot::Accent3, - deckcraft_color::SchemeSlot::Accent4, - deckcraft_color::SchemeSlot::Accent5, - deckcraft_color::SchemeSlot::Accent6, - ]; - egui::Grid::new("quickstyles").spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for row in 0..6 { - for (k, slot) in slots.iter().enumerate() { - let c = theme::to_color32(sc.get(*slot)); - let (rect, resp) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::click()); - let p = ui.painter(); - let (fill, stroke, text) = match row { - 0 => (Color32::WHITE, c, Color32::BLACK), - 2 => (c.gamma_multiply(0.35).to_opaque().lerp_to_gamma(Color32::WHITE, 0.6), Color32::TRANSPARENT, c), - 5 => (c.lerp_to_gamma(Color32::BLACK, 0.25), Color32::WHITE, Color32::WHITE), - _ => (c, c.lerp_to_gamma(Color32::BLACK, 0.3), Color32::WHITE), - }; - p.rect(rect.shrink(2.0), CornerRadius::same(3), fill, Stroke::new(1.5, stroke), egui::StrokeKind::Inside); - p.text(rect.center(), Align2::CENTER_CENTER, "Abc", theme::font(11.0), text); - if resp.hovered() { - p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); - } - if resp.clicked() { - run(app, "shape.quickStyle", json!({"index": row * 7 + k})); - ui.close(); - } - } - ui.end_row(); - } - }); -} - -pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); - for (l, id, ic) in [ - ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), - ("Send to Back", "arrange.sendToBack", Icon::SendToBack), - ("Bring Forward", "arrange.bringForward", Icon::BringForward), - ("Send Backward", "arrange.sendBackward", Icon::SendBackward), - ] { - menu_item(app, ui, ic, l, id, json!({})); - } - ui.separator(); - ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); - menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); - menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); - ui.separator(); - ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); - ui.menu_button("Align", |ui| align_menu(app, ui)); - ui.menu_button("Rotate", |ui| { - menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); - menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); - menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); - menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); - }); - if ui.button("Selection Pane…").clicked() { - let _ = app.run("view.pane", json!({"pane": "selection"})); - ui.close(); - } -} - -pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { - let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default(); - let to_slide = align_to_slide(app.align_to.as_ref(), &selection); - let to = if to_slide { "slide" } else { "selection" }; - for (l, e, ic) in [ - ("Align Left", "left", Icon::AlignObjLeft), - ("Align Center", "center", Icon::AlignObjCenter), - ("Align Right", "right", Icon::AlignObjRight), - ("Align Top", "top", Icon::AlignObjTop), - ("Align Middle", "middle", Icon::AlignObjMiddle), - ("Align Bottom", "bottom", Icon::AlignObjBottom), - ] { - menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to})); - } - ui.separator(); - menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to})); - menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to})); - ui.separator(); - let en = enabled(app, "arrange.align"); - for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] { - if widgets::check_item(ui, l, en, to_slide == slide).clicked() { - app.align_to = Some((selection.clone(), slide)); - ui.close(); - } - } -} - -/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and -/// several to each other, unless the other choice was ticked for this same selection. -fn align_to_slide(choice: Option<&(Vec, bool)>, selection: &[ShapeId]) -> bool { - match choice { - Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide, - _ => selection.len() < 2, - } -} - pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); let r = small_button(ui, icon, label, en, false); @@ -1285,2287 +342,6 @@ pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: & } } -fn home(app: &mut SlideApp, ui: &mut Ui) { - let st = fmt_state(app); - clipboard_group(app, ui); - slides_group(app, ui); - font_group(app, ui, &st); - paragraph_group(app, ui, &st); - group(ui, |ui| { - big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); - }); - drawing_group(app, ui); - // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. - group(ui, |ui| { - ui.vertical(|ui| { - small(app, ui, Icon::Search, "Find", "app.find", json!({})); - small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } - }); - // Also under Arrange, but that is disabled until something is selected (#132). - big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - }); - group(ui, |ui| { - big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); - }); -} - -// ---------- Insert ---------- - -fn insert(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); - if main.clicked() { - run(app, "slide.new", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).width(420.0).show(|ui| layout_menu(app, ui, "slide.new")); - } - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Table, "Table", enabled(app, "insert.table")); - egui::Popup::menu(&r).show(|ui| { - if let Some((rr, cc)) = table_grid_picker(ui) { - run(app, "insert.table", json!({"rows": rr, "cols": cc})); - ui.close(); - } - ui.separator(); - if ui.button("Insert Table…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("table")); - ui.close(); - } - if ui.button("Draw Table").clicked() { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); - ui.close(); - } - }); - }); - group(ui, |ui| { - big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); - photo_album_button(app, ui); - let r = big_button(ui, Icon::Screenshot, "Screenshot", false); - let _ = r.on_hover_text("Screenshot (not available yet)"); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); - egui::Popup::menu(&r).show(|ui| { - if let Some(p) = shapes_gallery(ui) { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); - } - }); - let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "insert.icon")); - egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(340.0).show(|ui| icon_library(app, ui)); - big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); - let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); - egui::Popup::menu(&r).show(|ui| { - for k in chart_menu_types() { - if k.is_none() { - ui.separator(); - } - if let Some(k) = k - && ui.button(k.label()).clicked() - { - run(app, "insert.chart", json!({"type": format!("{k:?}")})); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom")); - egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui)); - big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); - let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); - egui::Popup::menu(&r).show(|ui| { - for k in ["back", "forward", "beginning", "end", "home", "information", "return", "movie", "document", "sound", "help", "custom"] { - if ui.button(format!("Action Button: {k}")).clicked() { - run(app, "insert.actionButton", json!({"kind": k})); - } - } - ui.separator(); - let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); - if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("action")); - } - }); - }); - group(ui, |ui| { - big(app, ui, Icon::Comment, "Comment", "app.dialog", json!({"id": "comment"})); - }); - group(ui, |ui| { - big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); - big(app, ui, Icon::HeaderFooter, "Header &\nFooter", "app.dialog", json!({"id": "headerFooter"})); - let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt")); - egui::Popup::menu(&r).show(|ui| { - for i in 0..6 { - if ui.button(format!("WordArt style {}", i + 1)).clicked() { - run(app, "insert.wordArt", json!({"style": i})); - } - } - }); - big(app, ui, Icon::DateTime, "Date &\nTime", "insert.dateTime", json!({})); - big(app, ui, Icon::SlideNumber, "Slide\nNumber", "insert.slideNumber", json!({})); - }); - group(ui, |ui| { - big(app, ui, Icon::Equation, "Equation", "app.dialog", json!({"id": "equation"})); - big(app, ui, Icon::Symbol, "Symbol", "app.dialog", json!({"id": "symbol"})); - }); - group(ui, |ui| { - big(app, ui, Icon::Video, "Video", "app.insertVideoDialog", json!({})); - big(app, ui, Icon::Audio, "Audio", "app.insertAudioDialog", json!({})); - }); -} - -/// Hover-to-size table grid. Returns (rows, cols) when clicked. -pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { - let t = Tokens::get(ui.ctx()); - let id = ui.scope_id().with("tgrid"); - let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); - let cell = 16.0; - let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; - ui.label(label); - let (rect, resp) = ui.allocate_exact_size(vec2(10.0 * (cell + 2.0), 8.0 * (cell + 2.0)), Sense::click()); - if let Some(p) = resp.hover_pos() { - let c = ((p.x - rect.min.x) / (cell + 2.0)).floor() as usize + 1; - let r = ((p.y - rect.min.y) / (cell + 2.0)).floor() as usize + 1; - hover = (r.min(8), c.min(10)); - } - for r in 0..8 { - for c in 0..10 { - let cr = Rect::from_min_size(rect.min + vec2(c as f32 * (cell + 2.0), r as f32 * (cell + 2.0)), vec2(cell, cell)); - let on = r < hover.0 && c < hover.1; - ui.painter().rect( - cr, - CornerRadius::ZERO, - if on { t.accent.gamma_multiply(0.3) } else { t.card }, - Stroke::new(1.0, if on { t.accent } else { t.border }), - egui::StrokeKind::Inside, - ); - } - } - ui.data_mut(|d| d.insert_temp(id, hover)); - if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } -} - -fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) { - let mut cats: Vec<&str> = Vec::new(); - for icon in icons { - if !cats.contains(&icon.category) { - cats.push(icon.category); - } - } - for cat in cats { - ui.label(egui::RichText::new(cat).small().strong()); - ui.horizontal_wrapped(|ui| { - for icon in icons.iter().filter(|i| i.category == cat) { - icon_cell(app, ui, icon, color, hover); - } - }); - } -} - -fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) { - let mut n = 0; - ui.horizontal_wrapped(|ui| { - for icon in icons { - if icon.name.contains(q) || icon.label.to_lowercase().contains(q) { - icon_cell(app, ui, icon, color, hover); - n += 1; - } - } - }); - if n == 0 { - ui.label("No icons match."); - } -} - -/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline. -fn icon_library(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let (color, hover) = (t.text, t.hover); - let id = ui.scope_id().with("icon-query"); - let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); - ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); - let q = query.trim().to_lowercase(); - ui.data_mut(|d| d.insert_temp(id, query)); - let icons = deckcraft_engine::icons::icons(); - egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| { - if q.is_empty() { - render_icon_categories(app, ui, icons, color, hover); - } else { - render_filtered_icons(app, ui, icons, &q, color, hover); - } - }); -} - -fn icon_cell(app: &mut SlideApp, ui: &mut Ui, icon: &deckcraft_engine::icons::Icon, color: Color32, hover: Color32) { - let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), hover); - } - paint_icon(ui.painter(), r.shrink(6.0), icon, color); - if resp.on_hover_text(icon.label).clicked() { - let _ = app.run("insert.icon", json!({"name": icon.name})); - ui.close(); - } -} - -/// Stroke a Lucide outline into a gallery cell. The line is the design stroke scaled to the cell. -fn paint_icon(p: &egui::Painter, r: Rect, icon: &deckcraft_engine::icons::Icon, color: Color32) { - let Some(src) = deckcraft_engine::icons::outline(icon.name) else { return }; - let mut bp = src.clone(); - let (w, h) = (r.width().max(1.0) as f64, r.height().max(1.0) as f64); - bp.apply_affine(deckcraft_geom::Affine::scale_non_uniform(w / deckcraft_engine::icons::VIEW, h / deckcraft_engine::icons::VIEW)); - let sw = ((r.width() * icon.stroke as f32) / deckcraft_engine::icons::VIEW as f32).clamp(1.0, 3.5); - let mut pts: Vec = vec![]; - let mut polys: Vec<(Vec, bool)> = vec![]; - kurbo_flatten(&bp, |el| match el { - Fl::Move(x, y) => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), false)); - } - pts.clear(); - pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); - } - Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), - Fl::Close => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), true)); - } - } - }); - if pts.len() > 1 { - polys.push((pts, false)); - } - for (poly, closed) in polys { - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: poly, - closed, - fill: Color32::TRANSPARENT, - stroke: egui::epaint::PathStroke::new(sw, color), - })); - } -} - -// ---------- Draw ---------- - -fn draw(app: &mut SlideApp, ui: &mut Ui) { - use deckcraft_engine::ToolKind; - let cur = app.session.tool.kind.clone(); - group(ui, |ui| { - let sel = matches!(cur, ToolKind::Select); - if big_button_ex(ui, Icon::LassoSelect, "Select", true, false, sel).0.clicked() { - app.session.set_tool(ToolKind::Select); - } - let er = matches!(&cur, ToolKind::Ink { mode, .. } if mode == "eraser"); - if big_button_ex(ui, Icon::Eraser, "Eraser", true, false, er).0.clicked() { - app.session.set_tool(ToolKind::Ink { mode: "eraser".into(), color: deckcraft_color::Rgba::BLACK, width: 2.0 }); - } - }); - group(ui, |ui| { - let pens: [(Icon, &str, &str, deckcraft_color::Rgba, f64); 5] = [ - (Icon::Pen, "Black Pen", "pen", deckcraft_color::Rgba::rgb(20, 20, 20), 1.5), - (Icon::Pen, "Red Pen", "pen", deckcraft_color::Rgba::rgb(0xD8, 0x3F, 0x3F), 1.5), - (Icon::Pen, "Blue Pen", "pen", deckcraft_color::Rgba::rgb(0x2E, 0x6F, 0xD8), 1.5), - (Icon::Pencil, "Pencil", "pen", deckcraft_color::Rgba::rgb(90, 90, 90), 1.0), - (Icon::HighlighterPen, "Highlighter", "highlighter", deckcraft_color::Rgba::rgb(255, 230, 0), 10.0), - ]; - for (icon, label, mode, color, width) in pens { - let on = matches!(&cur, ToolKind::Ink { mode: m, color: c, .. } if m == mode && *c == color); - if big_button_ex(ui, icon, label, true, false, on).0.clicked() { - app.session.set_tool(ToolKind::Ink { mode: mode.into(), color, width }); - } - } - }); - group(ui, |ui| { - let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); - let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); - if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { - app.session.set_tool(ToolKind::Select); - run(app, "draw.inkToShape", json!({})); - } - let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); - let _ = r.on_hover_text("Ink to Text (coming soon)"); - let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); - let _ = r.on_hover_text("Ink to Math (coming soon)"); - }); -} - -// ---------- Design ---------- - -/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide -/// size controls. -fn design(app: &mut SlideApp, ui: &mut Ui) { - let themes = deckcraft_model::theme::builtin_themes(); - let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); - group(ui, |ui| { - let sel = themes.iter().position(|th| th.name == cur); - let names: Vec = themes.iter().map(|t| t.name.clone()).collect(); - if let Some(i) = widgets::gallery( - ui, - themes.len(), - vec2(84.0, 56.0), - sel, - |p, r, i| { - if let Some(th) = themes.get(i) { - paint_theme_tile(p, r, th); - } - }, - |i| names.get(i).cloned().unwrap_or_default(), - ) && let Some(n) = names.get(i) - { - run(app, "design.theme", json!({"name": n})); - } - if small_button(ui, Icon::Themes, "Browse for Themes…", enabled(app, "design.browseTheme"), false).clicked() { - run(app, "app.browseThemeDialog", json!({})); - } - }); - // Variants: the current theme in its four colour variations. - if let Some(th) = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.clone())) { - let variants = deckcraft_model::theme::theme_variants(&th); - let tiles: Vec = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect(); - let names: Vec = variants.iter().map(|c| c.name.clone()).collect(); - let sel = variants.iter().position(|c| c.colors == th.colors.colors); - group(ui, |ui| { - if let Some(i) = widgets::gallery( - ui, - tiles.len(), - vec2(84.0, 56.0), - sel, - |p, r, i| { - if let Some(t) = tiles.get(i) { - paint_theme_tile(p, r, t); - } - }, - |i| names.get(i).cloned().unwrap_or_default(), - ) { - run(app, "design.variant", json!({"index": i})); - } - }); - } - group(ui, |ui| { - let r = big_button(ui, Icon::Colors, "Colors", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors")); - egui::Popup::menu(&r).width(240.0).show(|ui| { - for cs in deckcraft_model::theme::builtin_color_schemes() { - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(vec2(220.0, 20.0), Sense::click()); - let p = ui.painter(); - for (k, c) in cs.colors.iter().take(10).skip(2).enumerate() { - p.rect_filled( - Rect::from_min_size(rect.min + vec2(k as f32 * 12.0, 3.0), vec2(11.0, 14.0)), - CornerRadius::ZERO, - theme::to_color32(*c), - ); - } - p.text(rect.min + vec2(104.0, 10.0), Align2::LEFT_CENTER, &cs.name, theme::font(12.0), Tokens::get(ui.ctx()).text); - if resp.clicked() { - run(app, "design.colors", json!({"name": cs.name})); - ui.close(); - } - }); - } - ui.separator(); - if ui.button("Customize Colors…").clicked() { - run(app, "design.colors", json!({})); - ui.close(); - } - }); - let r = big_button(ui, Icon::Fonts, "Fonts", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts")); - egui::Popup::menu(&r).width(220.0).show(|ui| { - for fs in deckcraft_model::theme::builtin_font_schemes() { - if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { - run(app, "design.fonts", json!({"name": fs.name})); - } - } - ui.separator(); - if ui.button("Customize Fonts…").clicked() { - run(app, "design.fonts", json!({})); - ui.close(); - } - }); - let r = big_button(ui, Icon::ShapeEffects, "Effects", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects")); - egui::Popup::menu(&r).width(180.0).show(|ui| { - for fs in deckcraft_model::theme::builtin_format_schemes() { - if ui.button(&fs.name).clicked() { - run(app, "design.effects", json!({"name": fs.name})); - ui.close(); - } - } - }); - let r = big_button(ui, Icon::BackgroundStyles, "Background\nStyles", true); - egui::Popup::menu(&r).show(|ui| { - egui::Grid::new("bgstyles").show(ui, |ui| { - let sc = scheme(app); - for i in 1..=12 { - let slots = [ - deckcraft_color::SchemeSlot::Lt1, - deckcraft_color::SchemeSlot::Lt2, - deckcraft_color::SchemeSlot::Dk2, - deckcraft_color::SchemeSlot::Dk1, - ]; - let base = theme::to_color32(sc.get(slots[(i - 1) % 4])); - let shade = match (i - 1) / 4 { - 0 => base, - 1 => base.lerp_to_gamma(Color32::GRAY, 0.15), - _ => base.lerp_to_gamma(Color32::BLACK, 0.12), - }; - let (rect, resp) = ui.allocate_exact_size(vec2(48.0, 28.0), Sense::click()); - ui.painter().rect(rect, CornerRadius::same(2), shade, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); - if resp.on_hover_text(format!("Style {i}")).clicked() { - run(app, "design.background", json!({"style": i, "all": true})); - ui.close(); - } - if i % 4 == 0 { - ui.end_row(); - } - } - }); - if ui.button("Reset Slide Background").clicked() { - run(app, "design.background", json!({"reset": true})); - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); - egui::Popup::menu(&r).show(|ui| { - if ui.button("Standard (4:3)").clicked() { - run(app, "design.slideSize", json!({"preset": "standard"})); - } - if ui.button("Widescreen (16:9)").clicked() { - run(app, "design.slideSize", json!({"preset": "widescreen"})); - } - if ui.button("Page Setup…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); - } - }); - big(app, ui, Icon::FormatBackground, "Format\nBackground", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); - }); -} - -pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) { - let bg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Lt1)); - let fg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Dk1)); - p.rect(r, CornerRadius::same(2), bg, Stroke::new(1.0, Color32::from_gray(200)), egui::StrokeKind::Inside); - p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, "Aa", theme::font(r.height() * 0.38), fg); - let w = (r.width() - 16.0) / 6.0; - for k in 0..6 { - let c = theme::to_color32(th.colors.colors.get(4 + k).copied().unwrap_or(deckcraft_color::Rgba::BLACK)); - p.rect_filled(Rect::from_min_size(pos2(r.min.x + 8.0 + k as f32 * w, r.max.y - 11.0), vec2(w - 1.5, 5.0)), CornerRadius::ZERO, c); - } -} - -// ---------- Transitions ---------- - -/// Render transition preview, gallery, options, sound, and timing controls for the current -/// slide. -fn transitions(app: &mut SlideApp, ui: &mut Ui) { - let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); - let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); - group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - } - } - }); - group(ui, |ui| { - let list = deckcraft_model::anim::TRANSITIONS; - let shown: Vec<_> = list.iter().take(12).collect(); - let sel = shown.iter().position(|x| x.0 == kind); - if let Some(i) = widgets::gallery( - ui, - shown.len(), - vec2(54.0, 56.0), - sel, - |p, r, i| paint_transition_tile(p, r, shown.get(i).map(|x| (x.0, x.1)).unwrap_or(("", ""))), - |i| shown.get(i).map(|x| x.1.to_string()).unwrap_or_default(), - ) && let Some(x) = shown.get(i) - { - run(app, "transition.set", json!({"kind": x.0})); - } - let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), true); - egui::Popup::menu(&more).width(480.0).show(|ui| { - for cat in ["Subtle", "Exciting", "Dynamic Content"] { - ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for x in list.iter().filter(|x| x.2 == cat) { - let (r, resp) = ui.allocate_exact_size(vec2(56.0, 58.0), Sense::click()); - if resp.hovered() || x.0 == kind { - ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); - } - paint_transition_tile(ui.painter(), r.shrink(3.0), (x.0, x.1)); - if resp.clicked() { - run(app, "transition.set", json!({"kind": x.0})); - ui.close(); - } - } - }); - } - }); - }); - group(ui, |ui| { - let selected = cur.as_ref().map(|t| t.option.as_str()); - let rows = transition_effect_rows(&kind, selected); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); - egui::Popup::menu(&r).show(|ui| { - for &(o, on) in &rows { - if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { - run(app, "transition.options", json!({"option": o})); - ui.close(); - } - } - }); - }); - group(ui, |ui| { - // Sound in its own column, so the timing column keeps its three rows. - rows(ui, |ui| { - ui.horizontal(|ui| { - let field = ui.label("Sound:"); - let stop = cur.as_ref().is_some_and(|t| t.stop_sound); - let name = cur - .as_ref() - .and_then(|t| t.sound) - .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) - .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); - let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { - let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); - if ui.selectable_label(none, "[No Sound]").clicked() { - run(app, "transition.sound", json!({"none": true})); - } - if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { - run(app, "transition.sound", json!({"stopPrevious": true})); - } - ui.separator(); - for (id, label) in deckcraft_engine::sounds::BUILTIN { - if ui.button(label).clicked() { - let lp = cur.as_ref().is_some_and(|t| t.sound_loop); - run(app, "transition.sound", json!({"builtin": id, "loop": lp})); - } - } - ui.separator(); - let can_pick = app.services.pick_open.is_some(); - let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); - if r.clicked() { - app.pick_transition_sound(); - } - r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); - ui.separator(); - let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); - let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); - if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { - run(app, "transition.sound", json!({"loop": lp})); - } - }); - let _ = combo.response.labelled_by(field.id); - }); - }); - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.label("Duration:"); - let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); - if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { - run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - ui.horizontal(|ui| { - let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click); - if ui.checkbox(&mut click, "On Mouse Click").changed() { - run(app, "transition.timing", json!({"onClick": click})); - } - }); - ui.horizontal(|ui| { - let after = cur.as_ref().and_then(|t| t.advance_after_ms); - let mut on = after.is_some(); - if ui.checkbox(&mut on, "After:").changed() { - run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); - } - if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { - run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - }); - big(app, ui, Icon::ApplyToAll, "Apply\nTo All", "transition.applyAll", json!({})); - }); -} - -fn option_label(o: &str) -> String { - match o { - "l" => "From Left".into(), - "r" => "From Right".into(), - "u" => "From Top".into(), - "d" => "From Bottom".into(), - "horz" => "Horizontal".into(), - "vert" => "Vertical".into(), - "in" => "In".into(), - "out" => "Out".into(), - other => { - let mut s = String::new(); - for (i, c) in other.chars().enumerate() { - if i == 0 { - s.extend(c.to_uppercase()); - } else if c.is_uppercase() { - s.push(' '); - s.push(c); - } else { - s.push(c); - } - } - s - } - } -} - -pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { - let t = Tokens::get(p.ctx()); - let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); - let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); - let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); - p.rect_filled(pic, CornerRadius::same(2), light); - match id { - "none" => { - p.rect_filled(pic, CornerRadius::same(2), Color32::from_rgb(0xE6, 0xEC, 0xF5)); - } - "fade" | "dissolve" | "flash" => { - p.rect_filled(pic, CornerRadius::same(2), blue.gamma_multiply(0.5)); - } - "push" | "cover" | "uncover" | "pan" | "conveyor" => { - p.rect_filled(Rect::from_min_max(pos2(pic.center().x, pic.min.y), pic.max), CornerRadius::same(2), blue); - p.arrow(pos2(pic.center().x + 10.0, pic.center().y), vec2(-12.0, 0.0), Stroke::new(1.5, Color32::WHITE)); - } - "wipe" | "reveal" => { - p.rect_filled(Rect::from_min_max(pic.min, pos2(pic.center().x, pic.max.y)), CornerRadius::same(2), blue); - } - "split" | "doors" | "window" | "curtains" => { - p.rect_filled( - Rect::from_min_max(pos2(pic.min.x + pic.width() * 0.3, pic.min.y), pos2(pic.max.x - pic.width() * 0.3, pic.max.y)), - CornerRadius::ZERO, - blue, - ); - } - "randomBar" | "blinds" | "comb" | "shred" => { - for k in 0..5 { - let x = pic.min.x + k as f32 * pic.width() / 5.0; - p.rect_filled(Rect::from_min_size(pos2(x, pic.min.y), vec2(pic.width() / 10.0, pic.height())), CornerRadius::ZERO, blue); - } - } - "checker" | "honeycomb" | "glitter" | "fracture" => { - for k in 0..12 { - if k % 2 == (k / 4) % 2 { - let x = pic.min.x + (k % 4) as f32 * pic.width() / 4.0; - let y = pic.min.y + (k / 4) as f32 * pic.height() / 3.0; - p.rect_filled(Rect::from_min_size(pos2(x, y), vec2(pic.width() / 4.0, pic.height() / 3.0)), CornerRadius::ZERO, blue); - } - } - } - "shape" | "zoom" | "ripple" | "vortex" | "clock" => { - p.circle_filled(pic.center(), pic.height() * 0.38, blue); - } - "morph" => { - p.circle_filled(pos2(pic.min.x + pic.width() * 0.3, pic.center().y), pic.height() * 0.25, blue); - p.rect_filled( - Rect::from_center_size(pos2(pic.max.x - pic.width() * 0.3, pic.center().y), vec2(pic.height() * 0.45, pic.height() * 0.45)), - CornerRadius::same(2), - blue.gamma_multiply(0.7), - ); - } - _ => { - // Fake 3-D: a tilted card. - let pts = vec![ - pos2(pic.min.x + 6.0, pic.min.y + 4.0), - pos2(pic.max.x - 3.0, pic.min.y + 1.0), - pos2(pic.max.x - 3.0, pic.max.y - 1.0), - pos2(pic.min.x + 6.0, pic.max.y - 4.0), - ]; - p.add(egui::Shape::convex_polygon(pts, blue, Stroke::NONE)); - } - } - p.rect_stroke(pic, CornerRadius::same(2), Stroke::new(1.0, Color32::from_rgb(0x9B, 0xB4, 0xDB)), egui::StrokeKind::Inside); - p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, label, theme::font(10.0), t.text); -} - -// ---------- Animations ---------- - -/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls. -fn animations(app: &mut SlideApp, ui: &mut Ui) { - let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); - let cur: Option = app.session.active().and_then(|d| { - let s = d.current_slide()?; - let id = d.selection.shapes.first()?; - s.animations.iter().find(|a| a.shape == *id).cloned() - }); - group(ui, |ui| { - let playing = app.anim_preview_active(); - let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); - if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { - run(app, "animation.preview", json!({})); - } - }); - group(ui, |ui| { - let quick: Vec<_> = deckcraft_model::anim::ANIMATIONS - .iter() - .filter(|a| { - matches!( - a.0, - "appear" - | "fade" - | "fly" - | "float" - | "split" - | "wipe" - | "zoom" - | "pulse" - | "spin" - | "growShrink" - | "fadeOut" - | "flyOut" - | "lines" - ) - }) - .collect(); - let sel = cur.as_ref().and_then(|c| quick.iter().position(|a| a.0 == c.effect)); - if let Some(i) = widgets::gallery( - ui, - quick.len(), - vec2(50.0, 56.0), - sel, - |p, r, i| { - if let Some(a) = quick.get(i) { - paint_anim_tile(p, r, a.1, a.2, has_sel); - } - }, - |i| quick.get(i).map(|a| a.1.to_string()).unwrap_or_default(), - ) && let Some(a) = quick.get(i) - { - run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); - } - let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), has_sel); - egui::Popup::menu(&more).width(420.0).show(|ui| { - for (cls, label) in - [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] - { - ui.label(egui::RichText::new(label).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 == cls) { - let (r, resp) = ui.allocate_exact_size(vec2(56.0, 54.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); - } - paint_anim_tile(ui.painter(), r.shrink(2.0), a.1, a.2, true); - if resp.clicked() { - run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); - ui.close(); - } - } - }); - } - }); - let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); - let build = cur.as_ref().map(|c| text_build_id(c.text_build)); - let build_rows = marked_options(&["asOne", "byParagraph"], build); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); - egui::Popup::menu(&r).show(|ui| { - for &(o, on) in &effect_rows { - if ui.selectable_label(on, option_label(o)).clicked() { - run(app, "animation.options", json!({"option": o})); - ui.close(); - } - } - ui.separator(); - for &(id, on) in &build_rows { - let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; - if ui.selectable_label(on, label).clicked() { - run(app, "animation.options", json!({"textBuild": id})); - ui.close(); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::AddAnimation, "Add\nAnimation", has_sel); - egui::Popup::menu(&r).width(260.0).show(|ui| { - for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 != AnimClass::Media) { - if ui - .button(format!( - "{} — {}", - a.1, - match a.2 { - AnimClass::Entrance => "Entrance", - AnimClass::Emphasis => "Emphasis", - AnimClass::Exit => "Exit", - _ => "Motion", - } - )) - .clicked() - { - run(app, "animation.add", json!({"effect": a.0, "class": a.2.xml()})); - } - } - }); - big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); - let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); - egui::Popup::menu(&r).show(|ui| { - if ui.button("On Click of… (none)").clicked() { - run(app, "animation.timing", json!({"trigger": null})); - } - let shapes: Vec<(u32, String)> = - app.session.active().map(|d| d.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect()).unwrap_or_default(); - for (id, name) in shapes { - if ui.button(format!("On Click of {name}")).clicked() { - run(app, "animation.timing", json!({"trigger": id})); - } - } - }); - let on = app.session.anim_painter.is_some(); - let en = enabled(app, "animation.painter"); - let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on); - let r = r.on_hover_text("Animation Painter (double-click to keep it on)"); - if r.double_clicked() { - run(app, "animation.painter", json!({"sticky": true})); - } else if r.clicked() { - if on { - app.session.anim_painter = None; - } else { - run(app, "animation.painter", json!({})); - } - } - }); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.label("Start:"); - let s = cur.as_ref().map(|c| match c.start { - deckcraft_model::AnimStart::OnClick => "On Click", - deckcraft_model::AnimStart::WithPrevious => "With Previous", - deckcraft_model::AnimStart::AfterPrevious => "After Previous", - }); - dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { - for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { - if ui.button(l).clicked() { - run(app, "animation.timing", json!({"start": v})); - ui.close(); - } - } - }); - }); - ui.horizontal(|ui| { - ui.label("Duration:"); - if let Some(v) = - widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) - { - run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - ui.horizontal(|ui| { - ui.label("Delay:"); - if let Some(v) = - widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) - { - run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - }); - }); -} - -pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { - let t = Tokens::get(p.ctx()); - let icon = match class { - AnimClass::Entrance => Icon::AnimEntrance, - AnimClass::Emphasis => Icon::AnimEmphasis, - AnimClass::Exit => Icon::AnimExit, - _ => Icon::AnimPath, - }; - icons::paint(p, Rect::from_center_size(pos2(r.center().x, r.min.y + 18.0), vec2(28.0, 28.0)), icon, t.text, !enabled); - p.text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, label, theme::font(10.0), if enabled { t.text } else { t.text_faint }); -} - -// ---------- Slide Show / Record / Review / View ---------- - -fn slide_show(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { - app.start_show(app.show_start_index(), false); - } - if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { - let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(i, false); - } - let on = app.ui.presenter_view; - if big_button_ex(ui, Icon::PresenterView, "Presenter\nView", true, false, on).0.clicked() { - app.ui.presenter_view = !on; - } - big(app, ui, Icon::CustomShow, "Custom\nShow", "app.dialog", json!({"id": "customShow"})); - }); - group(ui, |ui| { - big(app, ui, Icon::SetUpShow, "Set Up\nSlide Show", "app.dialog", json!({"id": "setupShow"})); - let hidden = app.session.active().and_then(|d| d.current_slide().map(|s| s.hidden)).unwrap_or(false); - if big_button_ex(ui, Icon::HideSlide, "Hide\nSlide", true, false, hidden).0.clicked() { - run(app, "slide.hide", json!({})); - } - }); - group(ui, |ui| { - if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { - app.start_show(app.show_start_index(), false); - if let Some(s) = app.show.as_mut() { - s.rehearse = true; - } - } - let r = big_button(ui, Icon::Record, "Record", false); - let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)"); - }); - group(ui, |ui| { - rows(ui, |ui| { - let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); - if ui.checkbox(&mut use_t, "Use Timings").changed() { - run(app, "show.setup", json!({"useTimings": use_t})); - } - let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); - if ui.checkbox(&mut narr, "Play Narrations").changed() { - run(app, "show.setup", json!({"noNarration": !narr})); - } - let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); - if ui.checkbox(&mut mc, "Show Media Controls").changed() { - run(app, "show.setup", json!({"showMediaControls": mc})); - } - }); - }); -} - -fn record(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::Cameo, "Cameo", false); - let _ = r.on_hover_text("Camera capture is not available yet"); - if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { - app.start_show(app.show_start_index(), false); - if let Some(s) = app.show.as_mut() { - s.rehearse = true; - } - } - if big_button(ui, Icon::PlayFromCurrent, "From\nCurrent Slide", true).clicked() { - let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(i, false); - if let Some(s) = app.show.as_mut() { - s.rehearse = true; - } - } - }); - group(ui, |ui| { - if big_button(ui, Icon::Help, "Learn\nMore", true).clicked() { - app.open_url(deckcraft_engine::links::APP_PAGE); - } - }); -} - -fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { - let (icon, label, id) = if forward { (Icon::NextComment, "Next", "comment.next") } else { (Icon::PrevComment, "Previous", "comment.prev") }; - if small_button(ui, icon, label, enabled(app, id), false).on_hover_text(tip(id, label)).clicked() { - let params = match app.ui.comment_focus { - Some((slide, index)) if app.session.active().is_some_and(|d| d.selection.slide == slide) => json!({"slide": slide, "index": index}), - _ => json!({}), - }; - if let Ok(v) = app.run(id, params) - && let (Some(slide), Some(index)) = (v.get("slide").and_then(Value::as_u64), v.get("index").and_then(Value::as_u64)) - && let (Ok(slide), Ok(index)) = (usize::try_from(slide), usize::try_from(index)) - { - app.ui.comment_focus = Some((slide, index)); - app.ui.pane = Some("comments".into()); - } - } -} - -fn review(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); - }); - group(ui, |ui| { - big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Translate, "Translate", false); - let _ = r.on_hover_text("Translation needs a language model (not bundled)"); - big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); - }); - group(ui, |ui| { - big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); - let comparing = enabled(app, "review.endCompare"); - if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { - run(app, "review.accept", json!({"all": true})); - } - if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { - run(app, "review.endCompare", json!({})); - if app.ui.pane.as_deref() == Some("revisions") { - app.ui.pane = None; - } - } - }); - group(ui, |ui| { - big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); - rows(ui, |ui| { - comment_nav(app, ui, false); - comment_nav(app, ui, true); - }); - big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); - let show = app.ui.pane.as_deref() == Some("comments"); - if big_button_ex(ui, Icon::ShowComments, "Show\nComments", true, false, show).0.clicked() { - let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); - } - }); -} - -fn view(app: &mut SlideApp, ui: &mut Ui) { - use crate::ViewMode; - group(ui, |ui| { - for (icon, label, id, mode) in [ - (Icon::ViewNormal, "Normal", "view.normal", Some(ViewMode::Normal)), - (Icon::ViewOutline, "Outline\nView", "view.outline", Some(ViewMode::Outline)), - (Icon::ViewSorter, "Slide\nSorter", "view.sorter", Some(ViewMode::Sorter)), - (Icon::ViewNotesPage, "Notes\nPage", "view.notesPage", Some(ViewMode::NotesPage)), - (Icon::ViewReading, "Reading\nView", "view.reading", None), - ] { - let sel = mode.is_some_and(|m| app.ui.view == m); - if big_button_ex(ui, icon, label, true, false, sel).0.clicked() { - let _ = app.run(id, json!({})); - } - } - }); - group(ui, |ui| { - let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); - if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() { - let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); - } - let in_handout = app.session.active().is_some_and(|d| d.selection.target == Target::Handout); - if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { - let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); - } - let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); - if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { - let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); - } - }); - group(ui, |ui| { - rows(ui, |ui| { - let mut r = app.ui.ruler; - if ui.checkbox(&mut r, "Ruler").changed() { - app.ui.ruler = r; - } - let mut g = app.ui.gridlines; - if ui.checkbox(&mut g, "Gridlines").changed() { - app.ui.gridlines = g; - } - let mut gd = app.ui.guides; - if ui.checkbox(&mut gd, "Guides").changed() { - app.ui.guides = gd; - } - }); - if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { - let _ = app.run("view.gridOptions", json!({})); - } - let n = app.ui.notes; - if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { - app.ui.notes = !n; - } - }); - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false); - if main.on_hover_text("Zoom").clicked() { - let _ = app.run("view.zoomDialog", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).show(|ui| { - for pct in [400, 200, 150, 100, 75, 66, 50, 33] { - if ui.button(format!("{pct}%")).clicked() { - let _ = app.run("view.zoom", json!({"percent": pct})); - ui.close(); - } - } - if ui.button("Fit").clicked() { - let _ = app.run("view.fit", json!({})); - ui.close(); - } - }); - } - if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() { - app.ui.zoom = None; - } - }); - group(ui, |ui| { - let has = app.session.active().is_some(); - let on = app.doc_window.is_some(); - let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0; - if resp.on_hover_text("Open another window on this presentation").clicked() { - let _ = app.run("view.newWindow", json!({})); - } - }); - group(ui, |ui| { - let gs = app.ui.grayscale; - if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() { - app.ui.grayscale = !gs; - } - let dark = app.ui.brightness == theme::Brightness::Dark; - if big_button_ex(ui, Icon::Palette, "Dark\nMode", true, false, dark).0.clicked() { - let _ = app.run("view.dark", json!({})); - } - }); -} - -// ---------- contextual tabs ---------- - -/// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. -fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { - let current = selected_warp(app); - ui.label(egui::RichText::new("Transform").font(theme::bold(12.0))); - ui.label("Follow Path"); - let mut picked = false; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 2.0); - for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| deckcraft_geom::warp::is_follow(p.id)) { - if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { - run(app, "format.textWarp", json!({"preset": p.id})); - picked = true; - } - } - }); - ui.label("Warp"); - egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 2.0); - if warp_tile(ui, "", "No Transform", current.is_none()) { - run(app, "format.textWarp", json!({"preset": "none"})); - picked = true; - } - for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| !deckcraft_geom::warp::is_follow(p.id)) { - if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { - run(app, "format.textWarp", json!({"preset": p.id})); - picked = true; - } - } - }); - }); - if picked { - ui.close(); - } -} - -fn selected_warp(app: &SlideApp) -> Option { - let doc = app.session.active()?; - let id = doc.selection.text.as_ref().map(|t| t.shape).or_else(|| doc.selection.shapes.first().copied())?; - doc.shape(id)?.text.as_ref()?.body.warp.clone() -} - -fn warp_tile(ui: &mut Ui, preset: &str, label: &str, selected: bool) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 28.0), Sense::click()); - if selected { - ui.painter().rect_filled(rect, CornerRadius::same(3), t.selected); - } else if resp.hovered() { - ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); - } - paint_warp_icon(ui.painter(), rect.shrink(4.0), (!preset.is_empty()).then_some(preset), t.text); - resp.on_hover_text(label).clicked() -} - -/// The preset's guides, or two straight lines for No Transform. -fn paint_warp_icon(p: &egui::Painter, r: Rect, name: Option<&str>, color: Color32) { - let Some(name) = name else { - let y0 = r.min.y + r.height() * 0.32; - let y1 = r.min.y + r.height() * 0.68; - p.line_segment([pos2(r.min.x, y0), pos2(r.max.x, y0)], Stroke::new(1.0, color)); - p.line_segment([pos2(r.min.x, y1), pos2(r.max.x, y1)], Stroke::new(1.0, color)); - return; - }; - let rect = deckcraft_geom::Rect::new((r.min.x + 1.0) as f64, (r.min.y + 1.0) as f64, (r.max.x - 1.0) as f64, (r.max.y - 1.0) as f64); - let Some(w) = deckcraft_geom::warp::warp(name, &[], rect) else { return }; - for band in &w.bands { - match band { - deckcraft_geom::warp::Band::Fill { top, bottom } => { - stroke_guide(p, top, color); - stroke_guide(p, bottom, color); - } - deckcraft_geom::warp::Band::Follow { path, .. } => stroke_guide(p, path, color), - } - } -} - -fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color32) { - let len = g.length(); - if !(len.is_finite() && len > 1.0) { - return; - } - let n = 20i32; - let mut pts = Vec::with_capacity((n + 1) as usize); - for i in 0..=n { - let (q, _) = g.at(len * f64::from(i) / f64::from(n)); - if q.x.is_finite() && q.y.is_finite() { - pts.push(pos2(q.x as f32, q.y as f32)); - } - } - if pts.len() >= 2 { - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: pts, - closed: false, - fill: Color32::TRANSPARENT, - stroke: Stroke::new(1.0, color).into(), - })); - } -} - -/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls. -fn shape_format(app: &mut SlideApp, ui: &mut Ui) { - let sc = scheme(app); - let sel = enabled(app, "shape.fill"); - group(ui, |ui| { - let r = big_button(ui, Icon::Shapes, "Shapes", true); - egui::Popup::menu(&r).show(|ui| { - if let Some(p) = shapes_gallery(ui) { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); - } - }); - big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); - let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); - egui::Popup::menu(&r).show(|ui| { - ui.label("Change Shape"); - if let Some(p) = shapes_gallery(ui) { - run(app, "shape.change", json!({"preset": p})); - } - ui.separator(); - if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { - run(app, "shape.editPoints", json!({"on": true})); - ui.close(); - } - ui.separator(); - smooth_corners_row(app, ui); - }); - let can_merge = enabled(app, "shape.merge"); - let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); - egui::Popup::menu(&r).show(|ui| { - for (op, label) in deckcraft_engine::cmd::merge::OPS { - if ui.button(label).clicked() { - run(app, "shape.merge", json!({"op": op})); - ui.close(); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::QuickStyles, "Shape\nStyles", sel); - egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); - rows(ui, |ui| { - let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); - if m.clicked() { - let c = app.last_fill.clone(); - run(app, "shape.fill", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { - match c { - Some(c) => { - app.last_fill = cref_param(&c); - run(app, "shape.fill", json!({"color": cref_param(&c)})); - } - None => run(app, "shape.fill", json!({"none": true})), - } - } - if ui.button("Gradient…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); - ui.close(); - } - }); - } - let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); - if m.clicked() { - let c = app.last_outline.clone(); - run(app, "shape.line", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); - } - let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); - } - }); - }); - group(ui, |ui| { - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_color_popup(&main, &arrow).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - run(app, "format.color", json!({"color": cref_param(&c)})); - } - if let Some(sh) = app.session.active().and_then(|d| d.selected_shapes().first().map(|s| (*s).clone())) { - ui.separator(); - crate::panes::text_transparency_slider(app, ui, &sh); - } - }); - } - let (main, arrow) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_color_popup(&main, &arrow).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { - run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))})); - } - }); - } - let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); - egui::Popup::menu(&r).width(312.0).show(|ui| { - if ui.button("Shadow").clicked() { - run(app, "format.textShadow", json!({})); - ui.close(); - } - ui.separator(); - transform_gallery(app, ui); - }); - }); - }); - group(ui, |ui| { - big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); - }); - arrange_group(app, ui); - size_group(app, ui); - group(ui, |ui| { - big(app, ui, Icon::FormatBackground, "Format\nPane", "view.pane", json!({"pane": "format"})); - }); -} - -/// Render ordering, align, group/ungroup, and rotate controls for the current selection. -fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let sel = enabled(app, "arrange.bringForward"); - let (m, a) = big_button_ex(ui, Icon::BringForward, "Bring\nForward", sel, true, false); - if m.clicked() { - run(app, "arrange.bringForward", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - menu_item(app, ui, Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); - menu_item(app, ui, Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); - }); - } - let (m, a) = big_button_ex(ui, Icon::SendBackward, "Send\nBackward", sel, true, false); - if m.clicked() { - run(app, "arrange.sendBackward", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - menu_item(app, ui, Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); - menu_item(app, ui, Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); - }); - } - big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - let r = big_button(ui, Icon::AlignObjects, "Align", sel); - egui::Popup::menu(&r).show(|ui| align_menu(app, ui)); - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); - if main.clicked() { - run(app, "arrange.group", json!({})); - } - if let Some(arrow) = arrow { - egui::Popup::menu(&arrow).show(|ui| { - menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); - menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); - }); - } - let (main, arrow) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); - if main.clicked() { - run(app, "arrange.rotateRight", json!({})); - } - if let Some(arrow) = arrow { - egui::Popup::menu(&arrow).show(|ui| { - menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); - menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); - menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); - menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); - }); - } - }); - }); -} - -/// Render width and height controls bound to the selected shape, plus the size-position dialog. -fn size_group(app: &mut SlideApp, ui: &mut Ui) { - let bx = app - .session - .active() - .and_then(|d| d.selection.shapes.first().and_then(|id| d.shape(*id).map(|s| deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, s)))); - group(ui, |ui| { - rows(ui, |ui| { - let Some(x) = bx else { - ui.label("Height:"); - ui.label("Width:"); - return; - }; - let len = app.session.prefs.units; - let (ppu, suffix) = (len.points_per_unit(), len.suffix()); - // " cm" needs more room than the inch mark. - let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; - ui.horizontal(|ui| { - ui.label("Height:"); - if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) { - run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); - } - }); - ui.horizontal(|ui| { - ui.label("Width: "); - if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) { - run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); - } - }); - }); - if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() { - run(app, "shape.sizePosition", json!({})); - } - }); -} - -/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, -/// border, and picture actions. -fn picture_format(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); - egui::Popup::menu(&r).show(|ui| { - for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { - if ui.button(label).clicked() { - run(app, "picture.removeBackground", json!({"tolerance": tol})); - } - } - }); - let r = big_button(ui, Icon::Corrections, "Corrections", true); - egui::Popup::menu(&r).show(|ui| { - for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { - if ui.button(format!("Brightness {:+}%", (b * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"brightness": b})); - } - } - ui.separator(); - for c in [-0.4, -0.2, 0.0, 0.2, 0.4] { - if ui.button(format!("Contrast {:+}%", (c * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"contrast": c})); - } - } - }); - let r = big_button(ui, Icon::ColorAdjust, "Color", true); - egui::Popup::menu(&r).show(|ui| { - for s in [0.0, 0.33, 0.66, 1.0, 1.5, 2.0, 3.0] { - if ui.button(format!("Saturation {}%", (s * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"saturation": s})); - } - } - ui.separator(); - ui.label("Recolor"); - let sc = scheme(app); - let presets = deckcraft_engine::cmd::recolor::presets(); - let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); - if let Some(i) = widgets::gallery( - ui, - presets.len(), - vec2(40.0, 30.0), - None, - |p, r, i| { - if let Some((id, _)) = presets.get(i) { - paint_recolor(p, r, id, &sc); - } - }, - |i| names.get(i).cloned().unwrap_or_default(), - ) && let Some((id, _)) = presets.get(i) - { - run(app, "picture.recolor", json!({"preset": id})); - } - ui.separator(); - // As in PowerPoint, the pointer picks the colour in the picture itself: a colour - // chosen from a grid almost never matches a photo's pixels. - if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() { - crate::eyedropper::request_transparent_color(ui.ctx()); - ui.close(); - } - }); - let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); - egui::Popup::menu(&r).show(|ui| { - if ui.button("None").clicked() { - run(app, "picture.artisticEffect", json!({"effect": "none"})); - } - for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { - if ui.button(*l).clicked() { - run(app, "picture.artisticEffect", json!({"effect": e})); - } - } - }); - let r = big_button(ui, Icon::Transparency, "Transparency", true); - egui::Popup::menu(&r).show(|ui| { - for tr in [0.0, 0.15, 0.3, 0.5, 0.65, 0.8, 0.95] { - if ui.button(format!("{}%", (tr * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"transparency": tr})); - } - } - }); - rows(ui, |ui| { - small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); - small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); - let (main, arrow) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); - if let Some(arrow) = arrow { - widgets::split_popup(&main, &arrow) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .width(260.0) - .show(|ui| compress_menu(app, ui)); - } - }); - }); - group(ui, |ui| { - use deckcraft_engine::cmd::picture_style::{STYLES, look}; - let names: Vec = STYLES.iter().map(|(_, l)| l.to_string()).collect(); - if let Some(i) = widgets::gallery( - ui, - STYLES.len(), - vec2(56.0, 44.0), - None, - |p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))), - |i| names.get(i).cloned().unwrap_or_default(), - ) && let Some((id, _)) = STYLES.get(i) - { - run(app, "picture.style", json!({"style": id})); - } - }); - group(ui, |ui| { - let sc = scheme(app); - let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true); - if m.clicked() { - run(app, "shape.line", json!({"color": "#000000", "width": 1.0})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { - match c { - Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), - None => run(app, "shape.line", json!({"none": true})), - } - } - ui.separator(); - ui.menu_button("Weight", |ui| { - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - if ui.button(format!("{w} pt")).clicked() { - run(app, "shape.line", json!({"width": w})); - } - } - }); - ui.menu_button("Dashes", |ui| { - for (l, d) in [ - ("Solid", "solid"), - ("Round Dot", "sysDot"), - ("Square Dot", "sysDash"), - ("Dash", "dash"), - ("Dash Dot", "dashDot"), - ("Long Dash", "lgDash"), - ] { - if ui.button(l).clicked() { - run(app, "shape.line", json!({"dash": d})); - } - } - }); - }); - if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() { - run(app, "picture.style", json!({"style": "none"})); - } - big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); - }); - arrange_group(app, ui); - group(ui, |ui| { - let r = big_button(ui, Icon::Crop, "Crop", true); - egui::Popup::menu(&r).show(|ui| { - ui.label("Crop to Shape"); - if let Some(p) = shapes_gallery(ui) { - run(app, "shape.change", json!({"preset": p})); - } - ui.separator(); - for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { - if ui.button(l).clicked() { - run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); - } - } - }); - }); - size_group(app, ui); -} - -fn table_design(app: &mut SlideApp, ui: &mut Ui) { - let tbl = app - .session - .active() - .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Table(t) = &s.kind { Some(t.clone()) } else { None })); - group(ui, |ui| { - rows(ui, |ui| { - let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); - ui.horizontal(|ui| { - let mut a = flags.0; - if ui.checkbox(&mut a, "Header Row").changed() { - run(app, "table.options", json!({"headerRow": a})); - } - let mut d = flags.3; - if ui.checkbox(&mut d, "First Column").changed() { - run(app, "table.options", json!({"firstColumn": d})); - } - }); - ui.horizontal(|ui| { - let mut b = flags.1; - if ui.checkbox(&mut b, "Total Row").changed() { - run(app, "table.options", json!({"totalRow": b})); - } - let mut e = flags.4; - if ui.checkbox(&mut e, "Last Column").changed() { - run(app, "table.options", json!({"lastColumn": e})); - } - }); - ui.horizontal(|ui| { - let mut c = flags.2; - if ui.checkbox(&mut c, "Banded Rows").changed() { - run(app, "table.options", json!({"bandedRows": c})); - } - let mut f = flags.5; - if ui.checkbox(&mut f, "Banded Columns").changed() { - run(app, "table.options", json!({"bandedColumns": f})); - } - }); - }); - }); - group(ui, |ui| { - let styles = deckcraft_model::table::table_styles(); - let quick: Vec<_> = styles.iter().filter(|(id, _)| id.starts_with("medium2") || id == "none" || id == "grid").take(8).collect(); - let cur = tbl.as_ref().map(|t| t.style.clone()).unwrap_or_default(); - let sel = quick.iter().position(|(id, _)| *id == cur); - let sc = scheme(app); - if let Some(i) = widgets::gallery( - ui, - quick.len(), - vec2(48.0, 40.0), - sel, - |p, r, i| { - if let Some((id, _)) = quick.get(i) { - paint_table_style(p, r, id, &sc); - } - }, - |i| quick.get(i).map(|x| x.1.clone()).unwrap_or_default(), - ) && let Some((id, _)) = quick.get(i) - { - run(app, "table.style", json!({"style": id})); - } - let more = widgets::drop_button(ui, "", vec2(14.0, 40.0), true); - egui::Popup::menu(&more).width(360.0).show(|ui| { - ui.horizontal_wrapped(|ui| { - for (id, label) in &styles { - let (r, resp) = ui.allocate_exact_size(vec2(48.0, 36.0), Sense::click()); - paint_table_style(ui.painter(), r.shrink(2.0), id, &sc); - if resp.on_hover_text(label).clicked() { - run(app, "table.style", json!({"style": id})); - ui.close(); - } - } - }); - }); - }); - group(ui, |ui| { - let sc = scheme(app); - let (m, a) = color_split(ui, Icon::Shading, "Shading", Color32::from_rgb(0x2E, 0x6F, 0xD8), true); - if m.clicked() { - run(app, "table.cellFill", json!({"color": "accent1"})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { - match c { - Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})), - None => run(app, "table.cellFill", json!({"none": true})), - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Borders, "Borders", true); - egui::Popup::menu(&r).show(|ui| { - let pen = app.table_pen.clone(); - for (label, which) in [ - ("No Border", "none"), - ("All Borders", "all"), - ("Outside Borders", "outside"), - ("Inside Borders", "inside"), - ("Top Border", "top"), - ("Bottom Border", "bottom"), - ("Left Border", "left"), - ("Right Border", "right"), - ("Inside Horizontal Border", "insideH"), - ("Inside Vertical Border", "insideV"), - ("Diagonal Down Border", "diagDown"), - ("Diagonal Up Border", "diagUp"), - ] { - let mut p = pen.clone(); - p["which"] = json!(which); - menu_item(app, ui, Icon::Borders, label, "table.borders", p); - } - ui.separator(); - ui.menu_button("Pen Weight", |ui| { - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); - if ui.radio(cur, format!("{w} pt")).clicked() { - app.table_pen["width"] = json!(w); - } - } - }); - ui.menu_button("Pen Style", |ui| { - for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { - let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; - if ui.radio(cur, l).clicked() { - app.table_pen["dash"] = json!(d); - } - } - }); - ui.menu_button("Pen Color", |ui| { - let sc = scheme(app); - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - app.table_pen["color"] = cref_param(&c); - } - }); - }); - }); - // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. - group(ui, |ui| { - use deckcraft_engine::ToolKind; - use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; - let cur = app.session.tool.kind.clone(); - for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { - let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); - if big_button_ex(ui, icon, label, true, false, on).0.clicked() { - app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); - } - } - }); -} - -/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame. -fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option) { - let Some(lk) = lk else { return }; - let r = r.shrink(7.0); - if lk.shadow.is_some() { - p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60)); - } - let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5); - let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c))); - let edge = Stroke::new(0.5, Color32::from_gray(150)); - match lk.preset { - "ellipse" => { - p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky)); - p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge))); - } - preset => { - let round = if preset == "rect" { 0 } else { 6 }; - p.rect_filled(r, CornerRadius::same(round), sky); - p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle); - } - } - // Hills, so the tile reads as a picture. - let base = r.bottom() - r.height() * 0.15; - p.line_segment( - [pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)], - Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), - ); - p.line_segment( - [pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)], - Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), - ); - if lk.reflection { - p.rect_filled( - Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)), - CornerRadius::ZERO, - sky.gamma_multiply(0.3), - ); - } -} - -/// A Recolor gallery tile: a dark-to-light ramp as the preset would draw it. -fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { - let mut adj = deckcraft_model::style::PictureAdjust::default(); - deckcraft_engine::cmd::recolor::apply(&mut adj, id); - let r = r.shrink(3.0); - let n = 8; - for k in 0..n { - let t = k as f64 / (n - 1) as f64; - // A colourful ramp, so "No Recolor" shows colour and the presets show their mapping. - let src = [t * 255.0, (0.4 + 0.6 * t) * 200.0, (1.0 - t) * 220.0]; - let y = 0.299 * src[0] + 0.587 * src[1] + 0.114 * src[2]; - let mut rgb = if adj.grayscale { [y; 3] } else { src }; - if adj.brightness != 0.0 || adj.contrast != 0.0 { - rgb = rgb.map(|v| (v - 127.5) * (1.0 + adj.contrast) + 127.5 + adj.brightness * 255.0); - } - if let Some((d, l)) = &adj.duotone { - let (d, l) = (d.resolve(sc, None), l.resolve(sc, None)); - let u = y / 255.0; - rgb = - [d.r as f64 + (l.r as f64 - d.r as f64) * u, d.g as f64 + (l.g as f64 - d.g as f64) * u, d.b as f64 + (l.b as f64 - d.b as f64) * u]; - } - if let Some(th) = adj.bilevel { - rgb = [if y >= th * 255.0 { 255.0 } else { 0.0 }; 3]; - } - let c = Color32::from_rgb(rgb[0].clamp(0.0, 255.0) as u8, rgb[1].clamp(0.0, 255.0) as u8, rgb[2].clamp(0.0, 255.0) as u8); - let w = r.width() / n as f32; - p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); - } - p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); -} - -pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { - let slot = match id.rsplit('-').next().unwrap_or("accent1") { - "accent2" => deckcraft_color::SchemeSlot::Accent2, - "accent3" => deckcraft_color::SchemeSlot::Accent3, - "accent4" => deckcraft_color::SchemeSlot::Accent4, - "accent5" => deckcraft_color::SchemeSlot::Accent5, - "accent6" => deckcraft_color::SchemeSlot::Accent6, - "tx1" => deckcraft_color::SchemeSlot::Dk1, - _ => deckcraft_color::SchemeSlot::Accent1, - }; - let a = theme::to_color32(sc.get(slot)); - let kind = id.split('-').next().unwrap_or(""); - for row in 0..4 { - let y = r.min.y + row as f32 * r.height() / 4.0; - let rr = Rect::from_min_size(pos2(r.min.x, y), vec2(r.width(), r.height() / 4.0)); - let c = match (kind, row) { - ("none", _) | ("grid", _) => Color32::WHITE, - ("light1", 0) | ("light2", _) => Color32::WHITE, - ("dark1", 0) => Color32::BLACK, - ("dark1", _) => a.lerp_to_gamma(Color32::BLACK, 0.3), - (_, 0) => a, - (_, k) if k % 2 == 1 => a.lerp_to_gamma(Color32::WHITE, 0.6), - _ => a.lerp_to_gamma(Color32::WHITE, 0.8), - }; - p.rect_filled(rr, CornerRadius::ZERO, c); - p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(0.5, if kind == "grid" { Color32::GRAY } else { Color32::WHITE })); - } - if kind == "grid" || kind == "light2" { - p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.8, if kind == "grid" { Color32::GRAY } else { a }), egui::StrokeKind::Inside); - } -} - -fn table_layout(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::DeleteTable, "Delete", true); - egui::Popup::menu(&r).show(|ui| { - menu_item(app, ui, Icon::DeleteTable, "Delete Columns", "table.deleteColumn", json!({})); - menu_item(app, ui, Icon::DeleteTable, "Delete Rows", "table.deleteRow", json!({})); - menu_item(app, ui, Icon::DeleteTable, "Delete Table", "edit.delete", json!({})); - }); - big(app, ui, Icon::InsertAbove, "Insert\nAbove", "table.insertRowAbove", json!({})); - big(app, ui, Icon::InsertBelow, "Insert\nBelow", "table.insertRowBelow", json!({})); - big(app, ui, Icon::InsertLeft, "Insert\nLeft", "table.insertColumnLeft", json!({})); - big(app, ui, Icon::InsertRight, "Insert\nRight", "table.insertColumnRight", json!({})); - }); - group(ui, |ui| { - big(app, ui, Icon::MergeCells, "Merge\nCells", "table.merge", json!({})); - big(app, ui, Icon::SplitCells, "Split\nCells", "table.split", json!({})); - }); - group(ui, |ui| { - rows(ui, |ui| { - small(app, ui, Icon::DistributeV, "Distribute Rows", "table.distributeRows", json!({})); - small(app, ui, Icon::DistributeH, "Distribute Columns", "table.distributeColumns", json!({})); - }); - }); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", false); - toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", false); - toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", false); - }); - ui.horizontal(|ui| { - for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { - run(app, "table.cellAlign", json!({"anchor": a})); - } - } - ui.menu_button("Cell Margins", |ui| { - for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { - if ui.button(l).clicked() { - run(app, "table.cellMargins", json!({"preset": preset})); - ui.close(); - } - } - }); - }); - }); - }); - arrange_group(app, ui); -} - -fn chart_design(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); - egui::Popup::menu(&r).show(|ui| { - for k in chart_menu_types() { - let Some(k) = k else { - ui.separator(); - continue; - }; - if ui.button(k.label()).clicked() { - // Replace the chart's type keeping its data. - if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { - let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); - } - } - } - }); - big(app, ui, Icon::EditData, "Edit\nData", "app.dialog", json!({"id": "chartData"})); - }); - arrange_group(app, ui); -} - -/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. -fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { - let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); - let row = app.ui.smartart_row; - let (gid, n, kind, style, colors) = match &sel { - Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), - None => (None, 0, String::new(), String::new(), String::new()), - }; - let at = row.min(n.saturating_sub(1)); - group(ui, |ui| { - if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { - run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); - } - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - for (label, pos) in [ - ("Add Shape After", "after"), - ("Add Shape Before", "before"), - ("Add Shape Above", "above"), - ("Add Shape Below", "below"), - ("Add Assistant", "assistant"), - ] { - if ui.button(label).clicked() { - let index = if n == 0 { Value::Null } else { json!(at) }; - run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); - } - } - }); - } - if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { - run(app, "smartArt.promote", json!({"id": gid, "index": at})); - } - if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { - run(app, "smartArt.demote", json!({"id": gid, "index": at})); - } - }); - let pictures = kind.starts_with("picture"); - if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) - .on_hover_text("Put a picture in the selected node's picture placeholder") - .clicked() - { - run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); - } - }); - group(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); - let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); - }); - } - let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - if ui.button("Convert to Text").clicked() { - run(app, "smartArt.toText", json!({"id": gid})); - } - if ui.button("Convert to Shapes").clicked() { - run(app, "smartArt.toShapes", json!({"id": gid})); - } - }); - } - }); - group(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - for (k, label) in deckcraft_engine::cmd::smartart::COLORS { - if matches!(k, "range23" | "accent1" | "accent1Grad") { - ui.separator(); - } - let on = colors == k || (colors.is_empty() && k == "colorful"); - if ui.selectable_label(on, label).clicked() { - run(app, "smartArt.colors", json!({"id": gid, "colors": k})); - } - } - }); - } - let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - for (k, label) in deckcraft_engine::cmd::smartart::STYLES { - if k == "polished" { - ui.separator(); - ui.label("3-D"); - } - let on = style == k || (style.is_empty() && k == "flat"); - if ui.selectable_label(on, label).clicked() { - run(app, "smartArt.style", json!({"id": gid, "style": k})); - } - } - }); - } - }); - arrange_group(app, ui); -} - -fn playback(app: &mut SlideApp, ui: &mut Ui) { - let m = app - .session - .active() - .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Media(m) = &s.kind { Some(m.clone()) } else { None })); - group(ui, |ui| { - let playing = app.session.active().and_then(|d| d.selection.shapes.first().copied()).is_some_and(|id| app.media.is_playing(id)); - if big_button(ui, if playing { Icon::Pause } else { Icon::Play }, if playing { "Pause" } else { "Play" }, m.is_some()).clicked() { - run(app, "media.toggle", json!({})); - } - big(app, ui, Icon::TrimMedia, "Trim", "app.dialog", json!({"id": "trim"})); - }); - group(ui, |ui| { - rows(ui, |ui| { - let mut auto = m.as_ref().is_some_and(|x| x.autoplay); - if ui.checkbox(&mut auto, "Start Automatically").changed() { - run(app, "media.options", json!({"autoplay": auto})); - } - let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); - if ui.checkbox(&mut across, "Play Across Slides").changed() { - run(app, "media.options", json!({"acrossSlides": across})); - } - let mut lp = m.as_ref().is_some_and(|x| x.loop_play); - if ui.checkbox(&mut lp, "Loop until Stopped").changed() { - run(app, "media.options", json!({"loop": lp})); - } - }); - rows(ui, |ui| { - let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); - if ui.checkbox(&mut hide, "Hide During Show").changed() { - run(app, "media.options", json!({"hide": hide})); - } - let mut rw = m.as_ref().is_some_and(|x| x.rewind); - if ui.checkbox(&mut rw, "Rewind after Playing").changed() { - run(app, "media.options", json!({"rewind": rw})); - } - let mut fs = m.as_ref().is_some_and(|x| x.full_screen); - if ui.checkbox(&mut fs, "Play Full Screen").changed() { - run(app, "media.options", json!({"fullScreen": fs})); - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Volume, "Volume", m.is_some()); - egui::Popup::menu(&r).show(|ui| { - for (l, v) in [("Low", 0.25), ("Medium", 0.5), ("High", 1.0), ("Mute", 0.0)] { - if ui.button(l).clicked() { - run(app, "media.options", json!({"volume": v})); - } - } - }); - }); -} - -/// Preserve, and whether the open master or layout has a title or any footer placeholder. -fn master_checks(app: &SlideApp) -> (bool, bool, bool) { - let Some(st) = app.session.active() else { return (false, false, false) }; - let preserve = match st.selection.target { - Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), - Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), - Target::Slides | Target::Handout | Target::NotesMaster => false, - }; - let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); - let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); - (preserve, title, footers) -} - -fn slide_master(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); - big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); - big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); - }); - group(ui, |ui| { - rows(ui, |ui| { - let (preserve, title, footers) = master_checks(app); - let mut on = preserve; - if ui.checkbox(&mut on, "Preserve").changed() { - run(app, "master.preserve", json!({"preserve": on})); - } - let mut on = title; - if ui.checkbox(&mut on, "Title").changed() { - run(app, "master.placeholders", json!({"title": on})); - } - let mut on = footers; - if ui.checkbox(&mut on, "Footers").changed() { - run(app, "master.placeholders", json!({"footers": on})); - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); - egui::Popup::menu(&r).show(|ui| { - for (l, k) in - [("Content", "content"), ("Text", "text"), ("Picture", "picture"), ("Chart", "chart"), ("Table", "table"), ("Media", "media")] - { - if ui.button(l).clicked() { - run(app, "master.insertPlaceholder", json!({"kind": k})); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Themes, "Themes", true); - egui::Popup::menu(&r).show(|ui| { - for th in deckcraft_model::theme::builtin_themes() { - if ui.button(&th.name).clicked() { - run(app, "design.theme", json!({"name": th.name})); - } - } - ui.separator(); - if ui.button("Browse for Themes…").clicked() { - run(app, "app.browseThemeDialog", json!({})); - } - }); - big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { - run(app, "view.closeMaster", json!({})); - } - }); -} - -fn notes_master(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - use deckcraft_model::PhType; - let shown = |app: &SlideApp, k: PhType| { - app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) - }; - for col in [ - [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], - [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], - ] { - rows(ui, |ui| { - for (label, key, k) in col { - let mut on = shown(app, k); - if ui.checkbox(&mut on, label).changed() { - run(app, "notes.placeholders", json!({ key: on })); - } - } - }); - } - }); - group(ui, |ui| { - big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { - run(app, "view.closeMaster", json!({})); - } - }); -} - -fn handout_master(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); - egui::Popup::menu(&r).show(|ui| { - for n in [1u8, 2, 3, 4, 6, 9] { - if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() { - app.ui.handout_per_page = n; - } - } - }); - }); - group(ui, |ui| { - let shown = |app: &SlideApp, k: deckcraft_model::PhType| { - app.session.active().and_then(|d| d.doc.handout_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) - }; - rows(ui, |ui| { - use deckcraft_model::PhType; - for (label, key, k) in [ - ("Header", "header", PhType::Header), - ("Date", "date", PhType::Date), - ("Footer", "footer", PhType::Footer), - ("Page Number", "pageNumber", PhType::SlideNum), - ] { - let mut on = shown(app, k); - if ui.checkbox(&mut on, label).changed() { - run(app, "handout.placeholders", json!({ key: on })); - } - } - }); - }); - group(ui, |ui| { - big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { - run(app, "view.closeMaster", json!({})); - } - }); -} - -/// The stored id is the one marked row. Every other id stays unmarked. -fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { - options.iter().copied().map(|o| (o, current == Some(o))).collect() -} - -/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. -fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { - let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); - marked_options(opts, current) -} - -/// One row of Animations ▸ Effect Options for the selected effect. -fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { - let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); - marked_options(opts, current) -} - -fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { - match build { - deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", - deckcraft_model::anim::TextBuild::ByWord => "byWord", - deckcraft_model::anim::TextBuild::ByLetter => "byLetter", - deckcraft_model::anim::TextBuild::AsOne => "asOne", - } -} - -/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). -fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { - let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); - let pts: Vec = match name { - "curve" => (0..=24).map(|i| i as f32 / 24.0).map(|u| at(u, 0.5 - 0.38 * (u * std::f32::consts::TAU).sin())).collect(), - "freeform" => { - [(0.0, 0.85), (0.15, 0.2), (0.55, 0.45), (0.8, 0.05), (1.0, 0.75), (0.45, 1.0), (0.0, 0.85)].iter().map(|(x, y)| at(*x, *y)).collect() - } - _ => (0..=40).map(|i| i as f32 / 40.0).map(|u| at(u, 0.5 + 0.35 * (u * 19.0).sin() * (1.0 - u * 0.4))).collect(), - }; - p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); -} - -/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator -/// (`None`), then the Office 2016 types not already listed. -fn chart_menu_types() -> Vec> { - use deckcraft_model::chart::ChartType; - let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); - v.push(None); - v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); - v -} - #[cfg(test)] mod tests { use super::*; @@ -3658,140 +434,3 @@ mod drawtable_tests; #[cfg(test)] mod inktoshape_tests; - -/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a -/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui -/// memory, per shape) so later moves re-smooth the same corners. -fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { - let Some((id, radius)) = app.session.active().and_then(|d| { - let id = *d.selection.shapes.first()?; - let sh = d.shape(id)?; - let x = sh.xfrm?; - let ss = x.w.min(x.h).max(0.0); - let own = match &sh.geom { - deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { - Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) - } - deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), - _ => None, - }; - Some((id, own)) - }) else { - return; - }; - let key = egui::Id::unique(("deckcraft-smooth-corners", id.0)); - let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); - if let Some(r) = radius { - state.0 = r; - } - let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); - ui.horizontal(|ui| { - let label = ui.label("Smooth Corners"); - let mut pct = state.1; - let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); - let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; - if resp.changed() { - state.1 = pct; - ui.data_mut(|d| d.insert_temp(key, state)); - } - // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). - if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { - run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); - } - }); -} - -/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's -/// Insert Zoom dialogs do (a checklist of slides or sections). -fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let id = egui::Id::unique("insert_zoom_menu"); - let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); - ui.horizontal(|ui| { - for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { - if ui.selectable_label(kind == k, label).clicked() && kind != k { - kind = k; - picked.clear(); - } - } - }); - ui.separator(); - let items: Vec = if kind == 1 { - doc.sections.iter().map(|s| s.name.clone()).collect() - } else { - doc.slides - .iter() - .enumerate() - .map(|(i, s)| { - let t = s.title(); - format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim()) - }) - .collect() - }; - if items.is_empty() { - ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); - } - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, label) in items.iter().enumerate() { - let mut on = picked.contains(&i); - if ui.checkbox(&mut on, label).changed() { - if on { - picked.push(i); - } else { - picked.retain(|x| *x != i); - } - } - } - }); - ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); - ui.separator(); - if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { - let mut v = picked.clone(); - v.sort_unstable(); - let p = match kind { - 1 => json!({"kind": "section", "sections": v}), - 2 => json!({"kind": "summary", "slides": v}), - _ => json!({"kind": "slide", "slides": v}), - }; - run(app, "insert.zoom", p); - ui.data_mut(|d| d.remove::<(u8, Vec)>(id)); - ui.close(); - } -} - -/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or -/// all, delete cropped areas. The choices last for the session. -fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { - let id = egui::Id::unique("compress_pictures_opts"); - let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); - ui.label(egui::RichText::new("Compression options").strong()); - ui.checkbox(&mut only_selected, "Apply only to this picture"); - ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures"); - ui.separator(); - ui.label(egui::RichText::new("Resolution").strong()); - for (v, label) in [ - (330, "HD (330 ppi): good quality for high-definition displays"), - (220, "Print (220 ppi): excellent quality on most printers and screens"), - (150, "Web (150 ppi): good for web pages and projectors"), - (96, "E-mail (96 ppi): minimise document size for sharing"), - ] { - ui.radio_value(&mut ppi, v, label); - } - ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); - ui.separator(); - if ui.button("Compress").clicked() { - let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); - match app.session.execute("picture.compress", &p) { - Ok(r) => { - let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024; - let msg = match r["pictures"].as_u64().unwrap_or(0) { - 0 => "Pictures are already at or below that resolution".to_string(), - n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])), - }; - app.set_status(msg); - } - Err(e) => app.set_status(e.to_string()), - } - ui.close(); - } -} diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs new file mode 100644 index 000000000..0bdf1929c --- /dev/null +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -0,0 +1,218 @@ +use super::transitions::option_label; +use super::*; + +// ---------- Animations ---------- + +/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls. +pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { + let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); + let cur: Option = app.session.active().and_then(|d| { + let s = d.current_slide()?; + let id = d.selection.shapes.first()?; + s.animations.iter().find(|a| a.shape == *id).cloned() + }); + group(ui, |ui| { + let playing = app.anim_preview_active(); + let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { + run(app, "animation.preview", json!({})); + } + }); + group(ui, |ui| { + let quick: Vec<_> = deckcraft_model::anim::ANIMATIONS + .iter() + .filter(|a| { + matches!( + a.0, + "appear" + | "fade" + | "fly" + | "float" + | "split" + | "wipe" + | "zoom" + | "pulse" + | "spin" + | "growShrink" + | "fadeOut" + | "flyOut" + | "lines" + ) + }) + .collect(); + let sel = cur.as_ref().and_then(|c| quick.iter().position(|a| a.0 == c.effect)); + if let Some(i) = widgets::gallery( + ui, + quick.len(), + vec2(50.0, 56.0), + sel, + |p, r, i| { + if let Some(a) = quick.get(i) { + paint_anim_tile(p, r, a.1, a.2, has_sel); + } + }, + |i| quick.get(i).map(|a| a.1.to_string()).unwrap_or_default(), + ) && let Some(a) = quick.get(i) + { + run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); + } + let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), has_sel); + egui::Popup::menu(&more).width(420.0).show(|ui| { + for (cls, label) in + [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] + { + ui.label(egui::RichText::new(label).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 == cls) { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 54.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); + } + paint_anim_tile(ui.painter(), r.shrink(2.0), a.1, a.2, true); + if resp.clicked() { + run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); + ui.close(); + } + } + }); + } + }); + let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let build = cur.as_ref().map(|c| text_build_id(c.text_build)); + let build_rows = marked_options(&["asOne", "byParagraph"], build); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); + egui::Popup::menu(&r).show(|ui| { + for &(o, on) in &effect_rows { + if ui.selectable_label(on, option_label(o)).clicked() { + run(app, "animation.options", json!({"option": o})); + ui.close(); + } + } + ui.separator(); + for &(id, on) in &build_rows { + let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; + if ui.selectable_label(on, label).clicked() { + run(app, "animation.options", json!({"textBuild": id})); + ui.close(); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::AddAnimation, "Add\nAnimation", has_sel); + egui::Popup::menu(&r).width(260.0).show(|ui| { + for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 != AnimClass::Media) { + if ui + .button(format!( + "{} — {}", + a.1, + match a.2 { + AnimClass::Entrance => "Entrance", + AnimClass::Emphasis => "Emphasis", + AnimClass::Exit => "Exit", + _ => "Motion", + } + )) + .clicked() + { + run(app, "animation.add", json!({"effect": a.0, "class": a.2.xml()})); + } + } + }); + big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); + let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); + egui::Popup::menu(&r).show(|ui| { + if ui.button("On Click of… (none)").clicked() { + run(app, "animation.timing", json!({"trigger": null})); + } + let shapes: Vec<(u32, String)> = + app.session.active().map(|d| d.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect()).unwrap_or_default(); + for (id, name) in shapes { + if ui.button(format!("On Click of {name}")).clicked() { + run(app, "animation.timing", json!({"trigger": id})); + } + } + }); + let on = app.session.anim_painter.is_some(); + let en = enabled(app, "animation.painter"); + let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on); + let r = r.on_hover_text("Animation Painter (double-click to keep it on)"); + if r.double_clicked() { + run(app, "animation.painter", json!({"sticky": true})); + } else if r.clicked() { + if on { + app.session.anim_painter = None; + } else { + run(app, "animation.painter", json!({})); + } + } + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Start:"); + let s = cur.as_ref().map(|c| match c.start { + deckcraft_model::AnimStart::OnClick => "On Click", + deckcraft_model::AnimStart::WithPrevious => "With Previous", + deckcraft_model::AnimStart::AfterPrevious => "After Previous", + }); + dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { + for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { + if ui.button(l).clicked() { + run(app, "animation.timing", json!({"start": v})); + ui.close(); + } + } + }); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + if let Some(v) = + widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) + { + run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + ui.horizontal(|ui| { + ui.label("Delay:"); + if let Some(v) = + widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) + { + run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + }); + }); +} + +pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { + let t = Tokens::get(p.ctx()); + let icon = match class { + AnimClass::Entrance => Icon::AnimEntrance, + AnimClass::Emphasis => Icon::AnimEmphasis, + AnimClass::Exit => Icon::AnimExit, + _ => Icon::AnimPath, + }; + icons::paint(p, Rect::from_center_size(pos2(r.center().x, r.min.y + 18.0), vec2(28.0, 28.0)), icon, t.text, !enabled); + p.text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, label, theme::font(10.0), if enabled { t.text } else { t.text_faint }); +} + +/// The stored id is the one marked row. Every other id stays unmarked. +pub(super) fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { + options.iter().copied().map(|o| (o, current == Some(o))).collect() +} + +/// One row of Animations ▸ Effect Options for the selected effect. +pub(super) fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); + marked_options(opts, current) +} + +pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { + match build { + deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", + deckcraft_model::anim::TextBuild::ByWord => "byWord", + deckcraft_model::anim::TextBuild::ByLetter => "byLetter", + deckcraft_model::anim::TextBuild::AsOne => "asOne", + } +} diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs new file mode 100644 index 000000000..f5b517bf1 --- /dev/null +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -0,0 +1,158 @@ +use super::*; + +pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { + ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); + for (l, id, ic) in [ + ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), + ("Send to Back", "arrange.sendToBack", Icon::SendToBack), + ("Bring Forward", "arrange.bringForward", Icon::BringForward), + ("Send Backward", "arrange.sendBackward", Icon::SendBackward), + ] { + menu_item(app, ui, ic, l, id, json!({})); + } + ui.separator(); + ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); + menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); + menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); + ui.separator(); + ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); + ui.menu_button("Align", |ui| align_menu(app, ui)); + ui.menu_button("Rotate", |ui| { + menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); + menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); + menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); + menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); + }); + if ui.button("Selection Pane…").clicked() { + let _ = app.run("view.pane", json!({"pane": "selection"})); + ui.close(); + } +} + +pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { + let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default(); + let to_slide = align_to_slide(app.align_to.as_ref(), &selection); + let to = if to_slide { "slide" } else { "selection" }; + for (l, e, ic) in [ + ("Align Left", "left", Icon::AlignObjLeft), + ("Align Center", "center", Icon::AlignObjCenter), + ("Align Right", "right", Icon::AlignObjRight), + ("Align Top", "top", Icon::AlignObjTop), + ("Align Middle", "middle", Icon::AlignObjMiddle), + ("Align Bottom", "bottom", Icon::AlignObjBottom), + ] { + menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to})); + } + ui.separator(); + menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to})); + menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to})); + ui.separator(); + let en = enabled(app, "arrange.align"); + for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] { + if widgets::check_item(ui, l, en, to_slide == slide).clicked() { + app.align_to = Some((selection.clone(), slide)); + ui.close(); + } + } +} + +/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and +/// several to each other, unless the other choice was ticked for this same selection. +fn align_to_slide(choice: Option<&(Vec, bool)>, selection: &[ShapeId]) -> bool { + match choice { + Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide, + _ => selection.len() < 2, + } +} + +/// Render ordering, align, group/ungroup, and rotate controls for the current selection. +pub(super) fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let sel = enabled(app, "arrange.bringForward"); + let (m, a) = big_button_ex(ui, Icon::BringForward, "Bring\nForward", sel, true, false); + if m.clicked() { + run(app, "arrange.bringForward", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + menu_item(app, ui, Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + }); + } + let (m, a) = big_button_ex(ui, Icon::SendBackward, "Send\nBackward", sel, true, false); + if m.clicked() { + run(app, "arrange.sendBackward", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + menu_item(app, ui, Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + }); + } + big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); + let r = big_button(ui, Icon::AlignObjects, "Align", sel); + egui::Popup::menu(&r).show(|ui| align_menu(app, ui)); + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); + if main.clicked() { + run(app, "arrange.group", json!({})); + } + if let Some(arrow) = arrow { + egui::Popup::menu(&arrow).show(|ui| { + menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); + menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); + }); + } + let (main, arrow) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); + if main.clicked() { + run(app, "arrange.rotateRight", json!({})); + } + if let Some(arrow) = arrow { + egui::Popup::menu(&arrow).show(|ui| { + menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); + menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); + menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); + menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); + }); + } + }); + }); +} + +/// Render width and height controls bound to the selected shape, plus the size-position dialog. +pub(super) fn size_group(app: &mut SlideApp, ui: &mut Ui) { + let bx = app + .session + .active() + .and_then(|d| d.selection.shapes.first().and_then(|id| d.shape(*id).map(|s| deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, s)))); + group(ui, |ui| { + rows(ui, |ui| { + let Some(x) = bx else { + ui.label("Height:"); + ui.label("Width:"); + return; + }; + let len = app.session.prefs.units; + let (ppu, suffix) = (len.points_per_unit(), len.suffix()); + // " cm" needs more room than the inch mark. + let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; + ui.horizontal(|ui| { + ui.label("Height:"); + if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) { + run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); + } + }); + ui.horizontal(|ui| { + ui.label("Width: "); + if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) { + run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); + } + }); + }); + if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() { + run(app, "shape.sizePosition", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/chart.rs b/crates/ui-egui/src/ribbon/chart.rs new file mode 100644 index 000000000..97431ab33 --- /dev/null +++ b/crates/ui-egui/src/ribbon/chart.rs @@ -0,0 +1,25 @@ +use super::arrange::arrange_group; +use super::insert::chart_menu_types; +use super::*; + +pub(super) fn chart_design(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); + egui::Popup::menu(&r).show(|ui| { + for k in chart_menu_types() { + let Some(k) = k else { + ui.separator(); + continue; + }; + if ui.button(k.label()).clicked() { + // Replace the chart's type keeping its data. + if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { + let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); + } + } + } + }); + big(app, ui, Icon::EditData, "Edit\nData", "app.dialog", json!({"id": "chartData"})); + }); + arrange_group(app, ui); +} diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs new file mode 100644 index 000000000..183059143 --- /dev/null +++ b/crates/ui-egui/src/ribbon/design.rs @@ -0,0 +1,170 @@ +use super::*; + +// ---------- Design ---------- + +/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide +/// size controls. +pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) { + let themes = deckcraft_model::theme::builtin_themes(); + let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); + group(ui, |ui| { + let sel = themes.iter().position(|th| th.name == cur); + let names: Vec = themes.iter().map(|t| t.name.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + themes.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(th) = themes.get(i) { + paint_theme_tile(p, r, th); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some(n) = names.get(i) + { + run(app, "design.theme", json!({"name": n})); + } + if small_button(ui, Icon::Themes, "Browse for Themes…", enabled(app, "design.browseTheme"), false).clicked() { + run(app, "app.browseThemeDialog", json!({})); + } + }); + // Variants: the current theme in its four colour variations. + if let Some(th) = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.clone())) { + let variants = deckcraft_model::theme::theme_variants(&th); + let tiles: Vec = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect(); + let names: Vec = variants.iter().map(|c| c.name.clone()).collect(); + let sel = variants.iter().position(|c| c.colors == th.colors.colors); + group(ui, |ui| { + if let Some(i) = widgets::gallery( + ui, + tiles.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(t) = tiles.get(i) { + paint_theme_tile(p, r, t); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) { + run(app, "design.variant", json!({"index": i})); + } + }); + } + group(ui, |ui| { + let r = big_button(ui, Icon::Colors, "Colors", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors")); + egui::Popup::menu(&r).width(240.0).show(|ui| { + for cs in deckcraft_model::theme::builtin_color_schemes() { + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(220.0, 20.0), Sense::click()); + let p = ui.painter(); + for (k, c) in cs.colors.iter().take(10).skip(2).enumerate() { + p.rect_filled( + Rect::from_min_size(rect.min + vec2(k as f32 * 12.0, 3.0), vec2(11.0, 14.0)), + CornerRadius::ZERO, + theme::to_color32(*c), + ); + } + p.text(rect.min + vec2(104.0, 10.0), Align2::LEFT_CENTER, &cs.name, theme::font(12.0), Tokens::get(ui.ctx()).text); + if resp.clicked() { + run(app, "design.colors", json!({"name": cs.name})); + ui.close(); + } + }); + } + ui.separator(); + if ui.button("Customize Colors…").clicked() { + run(app, "design.colors", json!({})); + ui.close(); + } + }); + let r = big_button(ui, Icon::Fonts, "Fonts", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts")); + egui::Popup::menu(&r).width(220.0).show(|ui| { + for fs in deckcraft_model::theme::builtin_font_schemes() { + if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { + run(app, "design.fonts", json!({"name": fs.name})); + } + } + ui.separator(); + if ui.button("Customize Fonts…").clicked() { + run(app, "design.fonts", json!({})); + ui.close(); + } + }); + let r = big_button(ui, Icon::ShapeEffects, "Effects", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects")); + egui::Popup::menu(&r).width(180.0).show(|ui| { + for fs in deckcraft_model::theme::builtin_format_schemes() { + if ui.button(&fs.name).clicked() { + run(app, "design.effects", json!({"name": fs.name})); + ui.close(); + } + } + }); + let r = big_button(ui, Icon::BackgroundStyles, "Background\nStyles", true); + egui::Popup::menu(&r).show(|ui| { + egui::Grid::new("bgstyles").show(ui, |ui| { + let sc = scheme(app); + for i in 1..=12 { + let slots = [ + deckcraft_color::SchemeSlot::Lt1, + deckcraft_color::SchemeSlot::Lt2, + deckcraft_color::SchemeSlot::Dk2, + deckcraft_color::SchemeSlot::Dk1, + ]; + let base = theme::to_color32(sc.get(slots[(i - 1) % 4])); + let shade = match (i - 1) / 4 { + 0 => base, + 1 => base.lerp_to_gamma(Color32::GRAY, 0.15), + _ => base.lerp_to_gamma(Color32::BLACK, 0.12), + }; + let (rect, resp) = ui.allocate_exact_size(vec2(48.0, 28.0), Sense::click()); + ui.painter().rect(rect, CornerRadius::same(2), shade, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); + if resp.on_hover_text(format!("Style {i}")).clicked() { + run(app, "design.background", json!({"style": i, "all": true})); + ui.close(); + } + if i % 4 == 0 { + ui.end_row(); + } + } + }); + if ui.button("Reset Slide Background").clicked() { + run(app, "design.background", json!({"reset": true})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Standard (4:3)").clicked() { + run(app, "design.slideSize", json!({"preset": "standard"})); + } + if ui.button("Widescreen (16:9)").clicked() { + run(app, "design.slideSize", json!({"preset": "widescreen"})); + } + if ui.button("Page Setup…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); + } + }); + big(app, ui, Icon::FormatBackground, "Format\nBackground", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); + }); +} + +pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) { + let bg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Lt1)); + let fg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Dk1)); + p.rect(r, CornerRadius::same(2), bg, Stroke::new(1.0, Color32::from_gray(200)), egui::StrokeKind::Inside); + p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, "Aa", theme::font(r.height() * 0.38), fg); + let w = (r.width() - 16.0) / 6.0; + for k in 0..6 { + let c = theme::to_color32(th.colors.colors.get(4 + k).copied().unwrap_or(deckcraft_color::Rgba::BLACK)); + p.rect_filled(Rect::from_min_size(pos2(r.min.x + 8.0 + k as f32 * w, r.max.y - 11.0), vec2(w - 1.5, 5.0)), CornerRadius::ZERO, c); + } +} diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs new file mode 100644 index 000000000..d1a38541e --- /dev/null +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -0,0 +1,45 @@ +use super::*; + +// ---------- Draw ---------- + +pub(super) fn draw(app: &mut SlideApp, ui: &mut Ui) { + use deckcraft_engine::ToolKind; + let cur = app.session.tool.kind.clone(); + group(ui, |ui| { + let sel = matches!(cur, ToolKind::Select); + if big_button_ex(ui, Icon::LassoSelect, "Select", true, false, sel).0.clicked() { + app.session.set_tool(ToolKind::Select); + } + let er = matches!(&cur, ToolKind::Ink { mode, .. } if mode == "eraser"); + if big_button_ex(ui, Icon::Eraser, "Eraser", true, false, er).0.clicked() { + app.session.set_tool(ToolKind::Ink { mode: "eraser".into(), color: deckcraft_color::Rgba::BLACK, width: 2.0 }); + } + }); + group(ui, |ui| { + let pens: [(Icon, &str, &str, deckcraft_color::Rgba, f64); 5] = [ + (Icon::Pen, "Black Pen", "pen", deckcraft_color::Rgba::rgb(20, 20, 20), 1.5), + (Icon::Pen, "Red Pen", "pen", deckcraft_color::Rgba::rgb(0xD8, 0x3F, 0x3F), 1.5), + (Icon::Pen, "Blue Pen", "pen", deckcraft_color::Rgba::rgb(0x2E, 0x6F, 0xD8), 1.5), + (Icon::Pencil, "Pencil", "pen", deckcraft_color::Rgba::rgb(90, 90, 90), 1.0), + (Icon::HighlighterPen, "Highlighter", "highlighter", deckcraft_color::Rgba::rgb(255, 230, 0), 10.0), + ]; + for (icon, label, mode, color, width) in pens { + let on = matches!(&cur, ToolKind::Ink { mode: m, color: c, .. } if m == mode && *c == color); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(ToolKind::Ink { mode: mode.into(), color, width }); + } + } + }); + group(ui, |ui| { + let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); + if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + app.session.set_tool(ToolKind::Select); + run(app, "draw.inkToShape", json!({})); + } + let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); + let _ = r.on_hover_text("Ink to Text (coming soon)"); + let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); + let _ = r.on_hover_text("Ink to Math (coming soon)"); + }); +} diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs new file mode 100644 index 000000000..3feb1b745 --- /dev/null +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -0,0 +1,391 @@ +use super::*; + +/// A ColorRef as a command colour parameter. +pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { + match &c.base { + deckcraft_model::ColorBase::Scheme { slot } => { + let mut o = serde_json::Map::new(); + o.insert("scheme".into(), json!(slot.xml_name())); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::Rgb { rgb } if c.mods.is_empty() => json!(format!("#{}", rgb.hex())), + deckcraft_model::ColorBase::Rgb { rgb } => { + let mut o = serde_json::Map::new(); + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::Preset { name } => { + let mut o = serde_json::Map::new(); + o.insert("preset".into(), json!(name)); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::System { name, last } => { + let mut o = serde_json::Map::new(); + o.insert("system".into(), json!(name)); + o.insert("last".into(), json!(format!("#{}", last.hex()))); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + } +} + +/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected +/// text. +/// A layout gallery grouped by category, each layout as a small cached picture with its name. +/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn +/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the +/// document. +pub(super) fn smartart_gallery( + app: &mut SlideApp, + ui: &mut Ui, + cmd: &str, + extra: Value, + current: &str, + style: &str, + colors: &str, + nodes: Option<&[deckcraft_model::DiagramNode]>, +) { + use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; + ui.set_min_width(440.0); + let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { + for (cat, title) in [ + ("list", "List"), + ("process", "Process"), + ("cycle", "Cycle"), + ("hierarchy", "Hierarchy"), + ("relationship", "Relationship"), + ("matrix", "Matrix"), + ("pyramid", "Pyramid"), + ("picture", "Picture"), + ] { + let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); + if kinds.is_empty() { + continue; + } + ui.label(egui::RichText::new(title).strong()); + egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { + for (i, (k, label)) in kinds.iter().enumerate() { + let ctx = ui.ctx().clone(); + let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); + let r = ui + .vertical_centered(|ui| { + ui.set_width(100.0); + let r = match &thumb { + Some(t) => { + ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) + } + None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), + }; + ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); + r.on_hover_text(*label) + }) + .inner; + if r.hovered() { + let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480)); + if let Some(t) = preview { + r.show_tooltip_ui(|ui| { + ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0))); + }); + } + } + if r.clicked() { + let mut args = extra.clone(); + args["kind"] = json!(k); + run(app, cmd, args); + ui.close(); + } + if i % 4 == 3 { + ui.end_row(); + } + } + }); + ui.separator(); + } + }); +} + +/// Shapes gallery popup body: every preset by category. Returns the chosen preset. +pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let mut out = None; + ui.set_max_width(330.0); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for cat in deckcraft_geom::preset::Category::ALL { + ui.label(egui::RichText::new(cat.label()).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + for p in deckcraft_geom::preset::CATALOG.iter().filter(|p| p.category == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + paint_preset(ui.painter(), r.shrink(4.0), p.name, Color32::TRANSPARENT, t.text); + if resp.on_hover_text(p.label).clicked() { + out = Some(p.name); + } + } + if cat == deckcraft_geom::preset::Category::Lines { + for (name, label) in deckcraft_engine::tools::FREEFORM_TOOLS { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text); + if resp.on_hover_text(label).clicked() { + out = Some(name); + } + } + } + }); + ui.add_space(4.0); + } + }); + if out.is_some() { + ui.close(); + } + out +} + +/// Draw a preset's outline (gallery icons). +pub fn paint_preset(p: &egui::Painter, r: Rect, name: &str, fill: Color32, stroke: Color32) { + let (w, h) = (r.width() as f64, r.height() as f64); + let Some(g) = deckcraft_geom::preset::build(name, w, h, &[]) else { return }; + for sp in &g.paths { + let mut pts: Vec = vec![]; + let mut polys: Vec<(Vec, bool)> = vec![]; + kurbo_flatten(&sp.path, |el| match el { + Fl::Move(x, y) => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), false)); + } + pts.clear(); + pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); + } + Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), + Fl::Close => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), true)); + } + } + }); + if pts.len() > 1 { + polys.push((pts, false)); + } + for (poly, closed) in polys { + if closed && fill != Color32::TRANSPARENT && sp.fill != deckcraft_geom::preset::FillMode::None { + p.add(egui::Shape::Path(egui::epaint::PathShape { points: poly.clone(), closed: true, fill, stroke: Stroke::NONE.into() })); + } + if sp.stroke { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: poly, + closed, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.0, stroke).into(), + })); + } + } + } +} + +pub(super) enum Fl { + Move(f64, f64), + Line(f64, f64), + Close, +} + +pub(super) fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { + deckcraft_geom::preset_flatten(path, &mut |el| match el { + deckcraft_geom::PathEl::MoveTo(p) => f(Fl::Move(p.x, p.y)), + deckcraft_geom::PathEl::LineTo(p) => f(Fl::Line(p.x, p.y)), + deckcraft_geom::PathEl::ClosePath => f(Fl::Close), + _ => {} + }); +} + +pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::ColorScheme) { + if let Some(c) = widgets::color_grid(ui, sc, Some("No Outline")) { + match c { + Some(c) => { + app.last_outline = cref_param(&c); + run(app, "shape.line", json!({"color": cref_param(&c)})); + } + None => run(app, "shape.line", json!({"none": true})), + } + } + ui.menu_button("Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + if ui.button(format!("{w} pt")).clicked() { + run(app, "shape.line", json!({"width": w})); + ui.close(); + } + } + }); + ui.menu_button("Dashes", |ui| { + for (l, d) in [ + ("Solid", "solid"), + ("Round Dot", "sysDot"), + ("Square Dot", "sysDash"), + ("Dash", "dash"), + ("Dash Dot", "dashDot"), + ("Long Dash", "lgDash"), + ("Long Dash Dot", "lgDashDot"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + ui.close(); + } + } + }); + ui.menu_button("Arrows", |ui| { + for (l, h, tl) in [ + ("None", "none", "none"), + ("Arrow at end", "none", "triangle"), + ("Arrow at start", "triangle", "none"), + ("Arrows at both ends", "triangle", "triangle"), + ("Stealth end", "none", "stealth"), + ("Oval ends", "oval", "oval"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"head": h, "tail": tl})); + ui.close(); + } + } + }); +} + +pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { + ui.menu_button("Shadow", |ui| { + for (l, v) in [ + ("No Shadow", "none"), + ("Outer", "outer"), + ("Bottom", "bottom"), + ("Centered", "center"), + ("Inner", "inner"), + ("Perspective", "perspective"), + ] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); + } + } + }); + ui.menu_button("Reflection", |ui| { + for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + ui.menu_button("Glow", |ui| { + if ui.button("No Glow").clicked() { + run(app, "shape.effects", json!({"glow": null})); + } + for r in [5.0, 8.0, 11.0, 18.0] { + if ui.button(format!("{r} pt glow")).clicked() { + run(app, "shape.effects", json!({"glow": r})); + } + } + }); + ui.menu_button("Soft Edges", |ui| { + for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { + if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { + run(app, "shape.effects", json!({"softEdges": r})); + } + } + }); + ui.menu_button("Bevel", |ui| { + for b in [ + "none", + "circle", + "relaxedInset", + "cross", + "coolSlant", + "angle", + "softRound", + "convex", + "slope", + "divot", + "riblet", + "hardEdge", + "artDeco", + ] { + if ui.button(b).clicked() { + run(app, "shape.effects", json!({"bevel": b})); + } + } + }); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); + } +} + +pub(super) fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let slots = [ + deckcraft_color::SchemeSlot::Dk1, + deckcraft_color::SchemeSlot::Accent1, + deckcraft_color::SchemeSlot::Accent2, + deckcraft_color::SchemeSlot::Accent3, + deckcraft_color::SchemeSlot::Accent4, + deckcraft_color::SchemeSlot::Accent5, + deckcraft_color::SchemeSlot::Accent6, + ]; + egui::Grid::new("quickstyles").spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for row in 0..6 { + for (k, slot) in slots.iter().enumerate() { + let c = theme::to_color32(sc.get(*slot)); + let (rect, resp) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::click()); + let p = ui.painter(); + let (fill, stroke, text) = match row { + 0 => (Color32::WHITE, c, Color32::BLACK), + 2 => (c.gamma_multiply(0.35).to_opaque().lerp_to_gamma(Color32::WHITE, 0.6), Color32::TRANSPARENT, c), + 5 => (c.lerp_to_gamma(Color32::BLACK, 0.25), Color32::WHITE, Color32::WHITE), + _ => (c, c.lerp_to_gamma(Color32::BLACK, 0.3), Color32::WHITE), + }; + p.rect(rect.shrink(2.0), CornerRadius::same(3), fill, Stroke::new(1.5, stroke), egui::StrokeKind::Inside); + p.text(rect.center(), Align2::CENTER_CENTER, "Abc", theme::font(11.0), text); + if resp.hovered() { + p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + } + if resp.clicked() { + run(app, "shape.quickStyle", json!({"index": row * 7 + k})); + ui.close(); + } + } + ui.end_row(); + } + }); +} + +/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). +fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { + let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); + let pts: Vec = match name { + "curve" => (0..=24).map(|i| i as f32 / 24.0).map(|u| at(u, 0.5 - 0.38 * (u * std::f32::consts::TAU).sin())).collect(), + "freeform" => { + [(0.0, 0.85), (0.15, 0.2), (0.55, 0.45), (0.8, 0.05), (1.0, 0.75), (0.45, 1.0), (0.0, 0.85)].iter().map(|(x, y)| at(*x, *y)).collect() + } + _ => (0..=40).map(|i| i as f32 / 40.0).map(|u| at(u, 0.5 + 0.35 * (u * 19.0).sin() * (1.0 - u * 0.4))).collect(), + }; + p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs new file mode 100644 index 000000000..826fba98f --- /dev/null +++ b/crates/ui-egui/src/ribbon/home.rs @@ -0,0 +1,471 @@ +use super::arrange::arrange_menu; +use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery, smartart_gallery}; +use super::*; + +// ---------- Home ---------- + +fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false); + if main.on_hover_text("Paste (⌘V)").clicked() { + run(app, "edit.paste", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + if ui.button("Paste").clicked() { + run(app, "edit.paste", json!({})); + } + if ui.button("Paste and Match Formatting").clicked() { + run(app, "edit.pasteText", json!({})); + } + if ui.button("Paste Special…").clicked() { + run(app, "app.pasteSpecial", json!({})); + } + if ui.button("Paste Formatting").clicked() { + run(app, "format.painterApply", json!({})); + } + }); + } + rows(ui, |ui| { + small(app, ui, Icon::Cut, "Cut", "edit.cut", json!({})); + small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); + let on = app.session.painter.is_some(); + let en = enabled(app, "format.painter"); + let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); + if r.double_clicked() { + run(app, "format.painter", json!({"sticky": true})); + } else if r.clicked() { + if on { + app.session.painter = None; + } else { + run(app, "format.painter", json!({})); + } + } + }); + }); +} + +pub(super) fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { + let layouts: Vec<(String, deckcraft_model::LayoutId)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id)).collect())) + .unwrap_or_default(); + egui::Grid::new(("layouts", cmd)).spacing(vec2(6.0, 6.0)).show(ui, |ui| { + for (k, (name, id)) in layouts.iter().enumerate() { + let idx = app.session.active().and_then(|d| d.doc.masters.first().and_then(|m| m.layouts.iter().position(|l| l.id == *id))); + let resp = ui.vertical(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 54.0), Sense::click()); + if let (Some(d), Some(li)) = (app.session.active(), idx) { + let img = deckcraft_render::render_layout( + &d.doc, + 0, + Some(li), + &deckcraft_render::RenderOpts { + scale: 192.0 / d.doc.slide_size.width.max(1.0), + edit: true, + size: Some((192, 108)), + ..Default::default() + }, + ); + let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); + let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke( + rect, + CornerRadius::ZERO, + Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5)).truncate()); + resp + }); + if resp.inner.clicked() { + run(app, cmd, json!({"layout": id.0})); + ui.close(); + } + if k % 4 == 3 { + ui.end_row(); + } + } + }); +} + +/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls. +fn slides_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); + if main.on_hover_text("New Slide (⇧⌘N)").clicked() { + run(app, "slide.new", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).width(420.0).show(|ui| new_slide_menu(app, ui)); + } + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| layout_menu(app, ui, "slide.layout")); + } + small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); + let (main, arrow) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| section_menu(app, ui)); + } + }); + }); +} + +/// The New Slide button's menu: layouts, duplication, outline import and slide reuse. +fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { + layout_menu(app, ui, "slide.new"); + ui.separator(); + if ui.button("Duplicate Selected Slides").clicked() { + run(app, "slide.duplicate", json!({})); + } + if ui.button("Slides from Outline…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("outline")); + } + let desktop = app.services.pick_open.is_some(); + for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { + let r = ui.add_enabled(desktop, egui::Button::new(label)); + if r.clicked() { + run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); + } + r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + } +} + +/// The Section split button's menu. +fn section_menu(app: &mut SlideApp, ui: &mut Ui) { + if ui.button("Add Section").clicked() { + run(app, "section.add", json!({})); + } + if ui.button("Remove All Sections").clicked() { + run(app, "section.removeAll", json!({})); + } +} + +pub fn font_families(app: &SlideApp) -> Vec { + let mut v = vec![]; + if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) { + v.push(m.theme.fonts.major.latin.clone()); + v.push(m.theme.fonts.minor.latin.clone()); + } + v.extend(deckcraft_fonts::FontDb::global().families()); + v.dedup(); + v +} + +fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { + let en = enabled(app, "format.bold"); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let size = st.get("size").and_then(Value::as_f64); + let fams = font_families(app); + let theme_fonts: Vec = fams.iter().take(2).cloned().collect(); + dropdown(ui, "font", &font, 150.0, en, |ui| { + for (k, f) in fams.iter().enumerate() { + let label = if k == 0 && theme_fonts.len() == 2 { + format!("{f} (Headings)") + } else if k == 1 && theme_fonts.len() == 2 { + format!("{f} (Body)") + } else { + f.clone() + }; + if ui.button(egui::RichText::new(label)).clicked() { + run(app, "format.font", json!({"family": f})); + ui.close(); + } + if k == 1 { + ui.separator(); + } + } + ui.separator(); + // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). + if ui.button("Rescan Installed Fonts").clicked() { + run(app, "font.rescan", json!({})); + ui.close(); + } + }); + let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); + dropdown(ui, "size", &size_text, 52.0, en, |ui| { + for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) { + if ui.button(widgets::fmt_num(*s, "")).clicked() { + run(app, "format.size", json!({"size": s})); + ui.close(); + } + } + }); + toggle(app, ui, Icon::FontGrow, "Increase Font Size", "format.grow", false); + toggle(app, ui, Icon::FontShrink, "Decrease Font Size", "format.shrink", false); + toggle(app, ui, Icon::ClearFormatting, "Clear All Formatting", "format.clear", false); + }); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + toggle(app, ui, Icon::Bold, "Bold", "format.bold", b("bold")); + toggle(app, ui, Icon::Italic, "Italic", "format.italic", b("italic")); + toggle(app, ui, Icon::Underline, "Underline", "format.underline", b("underline")); + toggle(app, ui, Icon::Strikethrough, "Strikethrough", "format.strikethrough", b("strike")); + toggle(app, ui, Icon::Superscript, "Superscript", "format.superscript", b("superscript")); + toggle(app, ui, Icon::Subscript, "Subscript", "format.subscript", b("subscript")); + let r = icon_toggle(ui, Icon::CharSpacing, "Character Spacing", en, false); + egui::Popup::menu(&r).show(|ui| { + for (label, pt) in [("Very Tight", -3.0), ("Tight", -1.5), ("Normal", 0.0), ("Loose", 3.0), ("Very Loose", 6.0)] { + if ui.button(label).clicked() { + run(app, "format.spacing", json!({"pt": pt})); + } + } + }); + let r = icon_toggle(ui, Icon::ChangeCase, "Change Case", en, false); + egui::Popup::menu(&r).show(|ui| { + for (label, mode) in [ + ("Sentence case.", "sentence"), + ("lowercase", "lower"), + ("UPPERCASE", "upper"), + ("Capitalize Each Word", "title"), + ("tOGGLE cASE", "toggle"), + ] { + if ui.button(label).clicked() { + run(app, "format.case", json!({"mode": mode})); + } + } + }); + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::Highlight, "Text Highlight Color", Color32::from_rgb(255, 230, 0), en); + if m.clicked() { + run(app, "format.highlight", json!({"color": "#FFFF00"})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) { + run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())})); + } + }); + let (m, a) = color_split(ui, Icon::FontColor, "Font Color", Color32::from_rgb(0xD8, 0x3F, 0x3F), en); + if m.clicked() { + run(app, "format.color", json!({"color": "#C00000"})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "format.color", json!({"color": cref_param(&c)})); + } + }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } + }); + }); + }); +} + +/// Render paragraph formatting controls using the current paragraph state. +fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { + let en = enabled(app, "format.bullets"); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); + if m.on_hover_text("Bullets").clicked() { + run(app, "format.bullets", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if ui.button("None").clicked() { + run(app, "format.bullets", json!({"on": false})); + } + for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { + if ui.button(egui::RichText::new(format!("{ch} Bullet")).size(14.0)).clicked() { + run(app, "format.bullets", json!({"on": true, "char": ch})); + } + } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "bulleted".into()); + app.dialog = Some(dlg); + } + }); + } + let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); + if m.on_hover_text("Numbering").clicked() { + run(app, "format.numbering", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if ui.button("None").clicked() { + run(app, "format.numbering", json!({"on": false})); + } + for (label, sch) in [ + ("1. 2. 3.", "arabicPeriod"), + ("1) 2) 3)", "arabicParenR"), + ("I. II. III.", "romanUcPeriod"), + ("i. ii. iii.", "romanLcPeriod"), + ("A. B. C.", "alphaUcPeriod"), + ("a) b) c)", "alphaLcParenR"), + ("a. b. c.", "alphaLcPeriod"), + ] { + if ui.button(label).clicked() { + run(app, "format.numbering", json!({"on": true, "scheme": sch})); + } + } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "numbered".into()); + app.dialog = Some(dlg); + } + }); + } + toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); + toggle(app, ui, Icon::IncreaseIndent, "Increase List Level", "format.indent", false); + let r = icon_toggle(ui, Icon::LineSpacing, "Line Spacing", en, false); + egui::Popup::menu(&r).show(|ui| { + for v in [1.0, 1.5, 2.0, 2.5, 3.0] { + if ui.button(format!("{v:.1}")).clicked() { + run(app, "format.lineSpacing", json!({"lines": v})); + } + } + if ui.button("Line Spacing Options…").clicked() { + run(app, "format.paragraphDialog", json!({})); + } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + }); + let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); + egui::Popup::menu(&r).show(|ui| { + for n in 1..=3 { + if ui.button(format!("{n} Column{}", if n > 1 { "s" } else { "" })).clicked() { + run(app, "format.columns", json!({"count": n, "spacing": 18})); + } + } + if ui.button("More Columns…").clicked() { + run(app, "format.columns", json!({})); + } + }); + }); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let al = st.get("align").and_then(Value::as_str).unwrap_or(""); + toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", al == "l"); + toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", al == "ctr"); + toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", al == "r"); + toggle(app, ui, Icon::Justify, "Justify", "format.justify", al == "just"); + let r = icon_toggle(ui, Icon::TextDirection, "Text Direction", en, false); + egui::Popup::menu(&r).show(|ui| { + for (l, d) in [ + ("Horizontal", "horizontal"), + ("Rotate all text 90°", "rotate90"), + ("Rotate all text 270°", "rotate270"), + ("Stacked", "stacked"), + ] { + if ui.button(l).clicked() { + run(app, "format.direction", json!({"dir": d})); + } + } + }); + let r = icon_toggle(ui, Icon::AlignText, "Align Text", en, false); + egui::Popup::menu(&r).show(|ui| { + for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { + if ui.button(l).clicked() { + run(app, "format.anchor", json!({"anchor": a})); + } + } + }); + let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); + if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + run(app, "format.paragraphDialog", json!({})); + } + }); + }); + }); +} + +/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline. +fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let en = enabled(app, "shape.insert"); + let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes"); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + big(app, ui, Icon::TextBox, "Text Box", "insert.textBox", json!({})); + let sel = enabled(app, "shape.fill"); + let r = big_button(ui, Icon::Arrange, "Arrange", sel); + egui::Popup::menu(&r).show(|ui| arrange_menu(app, ui)); + let r = big_button(ui, Icon::QuickStyles, "Quick\nStyles", sel); + egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); + rows(ui, |ui| { + let sc = scheme(app); + let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); + if m.clicked() { + let c = app.last_fill.clone(); + run(app, "shape.fill", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => { + app.last_fill = cref_param(&c); + run(app, "shape.fill", json!({"color": cref_param(&c)})); + } + None => run(app, "shape.fill", json!({"none": true})), + } + } + }); + } + let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if m.clicked() { + let c = app.last_outline.clone(); + run(app, "shape.line", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); + } + let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); + } + }); + }); +} + +pub(super) fn home(app: &mut SlideApp, ui: &mut Ui) { + let st = fmt_state(app); + clipboard_group(app, ui); + slides_group(app, ui); + font_group(app, ui, &st); + paragraph_group(app, ui, &st); + group(ui, |ui| { + big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); + }); + drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. + group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } + }); + // Also under Arrange, but that is disabled until something is selected (#132). + big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); + }); + group(ui, |ui| { + big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); + }); +} diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs new file mode 100644 index 000000000..da2b6bb46 --- /dev/null +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -0,0 +1,271 @@ +use super::galleries::{Fl, kurbo_flatten, shapes_gallery}; +use super::home::layout_menu; +use super::view::zoom_menu; +use super::*; + +/// Photo Album is a split button: the main click starts a new album, the arrow edits one. +fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { + let en = enabled(app, "insert.photoAlbum"); + let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); + let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); + if let Some(a) = &arrow { + let _ = a.clone().on_hover_text("Photo Album options"); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); + } + if main.clicked() { + run(app, "insert.photoAlbum", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::Picture, "New Photo Album…", "insert.photoAlbum", json!({})); + menu_item(app, ui, Icon::Picture, "Edit Photo Album…", "insert.photoAlbumEdit", json!({})); + }); + } +} + +// ---------- Insert ---------- + +pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); + if main.clicked() { + run(app, "slide.new", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).width(420.0).show(|ui| layout_menu(app, ui, "slide.new")); + } + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Table, "Table", enabled(app, "insert.table")); + egui::Popup::menu(&r).show(|ui| { + if let Some((rr, cc)) = table_grid_picker(ui) { + run(app, "insert.table", json!({"rows": rr, "cols": cc})); + ui.close(); + } + ui.separator(); + if ui.button("Insert Table…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("table")); + ui.close(); + } + if ui.button("Draw Table").clicked() { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); + ui.close(); + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); + photo_album_button(app, ui); + let r = big_button(ui, Icon::Screenshot, "Screenshot", false); + let _ = r.on_hover_text("Screenshot (not available yet)"); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "insert.icon")); + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(340.0).show(|ui| icon_library(app, ui)); + big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); + let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); + egui::Popup::menu(&r).show(|ui| { + for k in chart_menu_types() { + if k.is_none() { + ui.separator(); + } + if let Some(k) = k + && ui.button(k.label()).clicked() + { + run(app, "insert.chart", json!({"type": format!("{k:?}")})); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom")); + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui)); + big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); + let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); + egui::Popup::menu(&r).show(|ui| { + for k in ["back", "forward", "beginning", "end", "home", "information", "return", "movie", "document", "sound", "help", "custom"] { + if ui.button(format!("Action Button: {k}")).clicked() { + run(app, "insert.actionButton", json!({"kind": k})); + } + } + ui.separator(); + let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); + if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("action")); + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Comment, "Comment", "app.dialog", json!({"id": "comment"})); + }); + group(ui, |ui| { + big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); + big(app, ui, Icon::HeaderFooter, "Header &\nFooter", "app.dialog", json!({"id": "headerFooter"})); + let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt")); + egui::Popup::menu(&r).show(|ui| { + for i in 0..6 { + if ui.button(format!("WordArt style {}", i + 1)).clicked() { + run(app, "insert.wordArt", json!({"style": i})); + } + } + }); + big(app, ui, Icon::DateTime, "Date &\nTime", "insert.dateTime", json!({})); + big(app, ui, Icon::SlideNumber, "Slide\nNumber", "insert.slideNumber", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::Equation, "Equation", "app.dialog", json!({"id": "equation"})); + big(app, ui, Icon::Symbol, "Symbol", "app.dialog", json!({"id": "symbol"})); + }); + group(ui, |ui| { + big(app, ui, Icon::Video, "Video", "app.insertVideoDialog", json!({})); + big(app, ui, Icon::Audio, "Audio", "app.insertAudioDialog", json!({})); + }); +} + +/// Hover-to-size table grid. Returns (rows, cols) when clicked. +pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { + let t = Tokens::get(ui.ctx()); + let id = ui.scope_id().with("tgrid"); + let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); + let cell = 16.0; + let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; + ui.label(label); + let (rect, resp) = ui.allocate_exact_size(vec2(10.0 * (cell + 2.0), 8.0 * (cell + 2.0)), Sense::click()); + if let Some(p) = resp.hover_pos() { + let c = ((p.x - rect.min.x) / (cell + 2.0)).floor() as usize + 1; + let r = ((p.y - rect.min.y) / (cell + 2.0)).floor() as usize + 1; + hover = (r.min(8), c.min(10)); + } + for r in 0..8 { + for c in 0..10 { + let cr = Rect::from_min_size(rect.min + vec2(c as f32 * (cell + 2.0), r as f32 * (cell + 2.0)), vec2(cell, cell)); + let on = r < hover.0 && c < hover.1; + ui.painter().rect( + cr, + CornerRadius::ZERO, + if on { t.accent.gamma_multiply(0.3) } else { t.card }, + Stroke::new(1.0, if on { t.accent } else { t.border }), + egui::StrokeKind::Inside, + ); + } + } + ui.data_mut(|d| d.insert_temp(id, hover)); + if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } +} + +fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) { + let mut cats: Vec<&str> = Vec::new(); + for icon in icons { + if !cats.contains(&icon.category) { + cats.push(icon.category); + } + } + for cat in cats { + ui.label(egui::RichText::new(cat).small().strong()); + ui.horizontal_wrapped(|ui| { + for icon in icons.iter().filter(|i| i.category == cat) { + icon_cell(app, ui, icon, color, hover); + } + }); + } +} + +fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) { + let mut n = 0; + ui.horizontal_wrapped(|ui| { + for icon in icons { + if icon.name.contains(q) || icon.label.to_lowercase().contains(q) { + icon_cell(app, ui, icon, color, hover); + n += 1; + } + } + }); + if n == 0 { + ui.label("No icons match."); + } +} + +/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline. +fn icon_library(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (color, hover) = (t.text, t.hover); + let id = ui.scope_id().with("icon-query"); + let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); + ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); + let q = query.trim().to_lowercase(); + ui.data_mut(|d| d.insert_temp(id, query)); + let icons = deckcraft_engine::icons::icons(); + egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| { + if q.is_empty() { + render_icon_categories(app, ui, icons, color, hover); + } else { + render_filtered_icons(app, ui, icons, &q, color, hover); + } + }); +} + +fn icon_cell(app: &mut SlideApp, ui: &mut Ui, icon: &deckcraft_engine::icons::Icon, color: Color32, hover: Color32) { + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), hover); + } + paint_icon(ui.painter(), r.shrink(6.0), icon, color); + if resp.on_hover_text(icon.label).clicked() { + let _ = app.run("insert.icon", json!({"name": icon.name})); + ui.close(); + } +} + +/// Stroke a Lucide outline into a gallery cell. The line is the design stroke scaled to the cell. +fn paint_icon(p: &egui::Painter, r: Rect, icon: &deckcraft_engine::icons::Icon, color: Color32) { + let Some(src) = deckcraft_engine::icons::outline(icon.name) else { return }; + let mut bp = src.clone(); + let (w, h) = (r.width().max(1.0) as f64, r.height().max(1.0) as f64); + bp.apply_affine(deckcraft_geom::Affine::scale_non_uniform(w / deckcraft_engine::icons::VIEW, h / deckcraft_engine::icons::VIEW)); + let sw = ((r.width() * icon.stroke as f32) / deckcraft_engine::icons::VIEW as f32).clamp(1.0, 3.5); + let mut pts: Vec = vec![]; + let mut polys: Vec<(Vec, bool)> = vec![]; + kurbo_flatten(&bp, |el| match el { + Fl::Move(x, y) => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), false)); + } + pts.clear(); + pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); + } + Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), + Fl::Close => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), true)); + } + } + }); + if pts.len() > 1 { + polys.push((pts, false)); + } + for (poly, closed) in polys { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: poly, + closed, + fill: Color32::TRANSPARENT, + stroke: egui::epaint::PathStroke::new(sw, color), + })); + } +} + +/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator +/// (`None`), then the Office 2016 types not already listed. +pub(super) fn chart_menu_types() -> Vec> { + use deckcraft_model::chart::ChartType; + let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); + v.push(None); + v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); + v +} diff --git a/crates/ui-egui/src/ribbon/masters.rs b/crates/ui-egui/src/ribbon/masters.rs new file mode 100644 index 000000000..c39d2f158 --- /dev/null +++ b/crates/ui-egui/src/ribbon/masters.rs @@ -0,0 +1,141 @@ +use super::*; + +/// Preserve, and whether the open master or layout has a title or any footer placeholder. +fn master_checks(app: &SlideApp) -> (bool, bool, bool) { + let Some(st) = app.session.active() else { return (false, false, false) }; + let preserve = match st.selection.target { + Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), + Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), + Target::Slides | Target::Handout | Target::NotesMaster => false, + }; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); + (preserve, title, footers) +} + +pub(super) fn slide_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); + big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); + big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); + }); + group(ui, |ui| { + rows(ui, |ui| { + let (preserve, title, footers) = master_checks(app); + let mut on = preserve; + if ui.checkbox(&mut on, "Preserve").changed() { + run(app, "master.preserve", json!({"preserve": on})); + } + let mut on = title; + if ui.checkbox(&mut on, "Title").changed() { + run(app, "master.placeholders", json!({"title": on})); + } + let mut on = footers; + if ui.checkbox(&mut on, "Footers").changed() { + run(app, "master.placeholders", json!({"footers": on})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); + egui::Popup::menu(&r).show(|ui| { + for (l, k) in + [("Content", "content"), ("Text", "text"), ("Picture", "picture"), ("Chart", "chart"), ("Table", "table"), ("Media", "media")] + { + if ui.button(l).clicked() { + run(app, "master.insertPlaceholder", json!({"kind": k})); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Themes, "Themes", true); + egui::Popup::menu(&r).show(|ui| { + for th in deckcraft_model::theme::builtin_themes() { + if ui.button(&th.name).clicked() { + run(app, "design.theme", json!({"name": th.name})); + } + } + ui.separator(); + if ui.button("Browse for Themes…").clicked() { + run(app, "app.browseThemeDialog", json!({})); + } + }); + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + +pub(super) fn notes_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + use deckcraft_model::PhType; + let shown = |app: &SlideApp, k: PhType| { + app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + for col in [ + [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], + [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], + ] { + rows(ui, |ui| { + for (label, key, k) in col { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "notes.placeholders", json!({ key: on })); + } + } + }); + } + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + +pub(super) fn handout_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); + egui::Popup::menu(&r).show(|ui| { + for n in [1u8, 2, 3, 4, 6, 9] { + if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() { + app.ui.handout_per_page = n; + } + } + }); + }); + group(ui, |ui| { + let shown = |app: &SlideApp, k: deckcraft_model::PhType| { + app.session.active().and_then(|d| d.doc.handout_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + rows(ui, |ui| { + use deckcraft_model::PhType; + for (label, key, k) in [ + ("Header", "header", PhType::Header), + ("Date", "date", PhType::Date), + ("Footer", "footer", PhType::Footer), + ("Page Number", "pageNumber", PhType::SlideNum), + ] { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "handout.placeholders", json!({ key: on })); + } + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs new file mode 100644 index 000000000..73c3bde65 --- /dev/null +++ b/crates/ui-egui/src/ribbon/picture_format.rs @@ -0,0 +1,278 @@ +use super::arrange::{arrange_group, size_group}; +use super::galleries::{cref_param, shapes_gallery}; +use super::*; + +/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, +/// border, and picture actions. +pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); + egui::Popup::menu(&r).show(|ui| { + for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { + if ui.button(label).clicked() { + run(app, "picture.removeBackground", json!({"tolerance": tol})); + } + } + }); + let r = big_button(ui, Icon::Corrections, "Corrections", true); + egui::Popup::menu(&r).show(|ui| { + for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { + if ui.button(format!("Brightness {:+}%", (b * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"brightness": b})); + } + } + ui.separator(); + for c in [-0.4, -0.2, 0.0, 0.2, 0.4] { + if ui.button(format!("Contrast {:+}%", (c * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"contrast": c})); + } + } + }); + let r = big_button(ui, Icon::ColorAdjust, "Color", true); + egui::Popup::menu(&r).show(|ui| { + for s in [0.0, 0.33, 0.66, 1.0, 1.5, 2.0, 3.0] { + if ui.button(format!("Saturation {}%", (s * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"saturation": s})); + } + } + ui.separator(); + ui.label("Recolor"); + let sc = scheme(app); + let presets = deckcraft_engine::cmd::recolor::presets(); + let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + presets.len(), + vec2(40.0, 30.0), + None, + |p, r, i| { + if let Some((id, _)) = presets.get(i) { + paint_recolor(p, r, id, &sc); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = presets.get(i) + { + run(app, "picture.recolor", json!({"preset": id})); + } + ui.separator(); + // As in PowerPoint, the pointer picks the colour in the picture itself: a colour + // chosen from a grid almost never matches a photo's pixels. + if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() { + crate::eyedropper::request_transparent_color(ui.ctx()); + ui.close(); + } + }); + let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); + egui::Popup::menu(&r).show(|ui| { + if ui.button("None").clicked() { + run(app, "picture.artisticEffect", json!({"effect": "none"})); + } + for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { + if ui.button(*l).clicked() { + run(app, "picture.artisticEffect", json!({"effect": e})); + } + } + }); + let r = big_button(ui, Icon::Transparency, "Transparency", true); + egui::Popup::menu(&r).show(|ui| { + for tr in [0.0, 0.15, 0.3, 0.5, 0.65, 0.8, 0.95] { + if ui.button(format!("{}%", (tr * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"transparency": tr})); + } + } + }); + rows(ui, |ui| { + small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); + small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); + let (main, arrow) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); + if let Some(arrow) = arrow { + widgets::split_popup(&main, &arrow) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(260.0) + .show(|ui| compress_menu(app, ui)); + } + }); + }); + group(ui, |ui| { + use deckcraft_engine::cmd::picture_style::{STYLES, look}; + let names: Vec = STYLES.iter().map(|(_, l)| l.to_string()).collect(); + if let Some(i) = widgets::gallery( + ui, + STYLES.len(), + vec2(56.0, 44.0), + None, + |p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))), + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = STYLES.get(i) + { + run(app, "picture.style", json!({"style": id})); + } + }); + group(ui, |ui| { + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true); + if m.clicked() { + run(app, "shape.line", json!({"color": "#000000", "width": 1.0})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + match c { + Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), + None => run(app, "shape.line", json!({"none": true})), + } + } + ui.separator(); + ui.menu_button("Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + if ui.button(format!("{w} pt")).clicked() { + run(app, "shape.line", json!({"width": w})); + } + } + }); + ui.menu_button("Dashes", |ui| { + for (l, d) in [ + ("Solid", "solid"), + ("Round Dot", "sysDot"), + ("Square Dot", "sysDash"), + ("Dash", "dash"), + ("Dash Dot", "dashDot"), + ("Long Dash", "lgDash"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + } + } + }); + }); + if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() { + run(app, "picture.style", json!({"style": "none"})); + } + big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); + }); + arrange_group(app, ui); + group(ui, |ui| { + let r = big_button(ui, Icon::Crop, "Crop", true); + egui::Popup::menu(&r).show(|ui| { + ui.label("Crop to Shape"); + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + ui.separator(); + for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { + if ui.button(l).clicked() { + run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); + } + } + }); + }); + size_group(app, ui); +} + +/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame. +fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option) { + let Some(lk) = lk else { return }; + let r = r.shrink(7.0); + if lk.shadow.is_some() { + p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60)); + } + let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5); + let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c))); + let edge = Stroke::new(0.5, Color32::from_gray(150)); + match lk.preset { + "ellipse" => { + p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky)); + p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge))); + } + preset => { + let round = if preset == "rect" { 0 } else { 6 }; + p.rect_filled(r, CornerRadius::same(round), sky); + p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle); + } + } + // Hills, so the tile reads as a picture. + let base = r.bottom() - r.height() * 0.15; + p.line_segment( + [pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)], + Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), + ); + p.line_segment( + [pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)], + Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), + ); + if lk.reflection { + p.rect_filled( + Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)), + CornerRadius::ZERO, + sky.gamma_multiply(0.3), + ); + } +} + +/// A Recolor gallery tile: a dark-to-light ramp as the preset would draw it. +fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { + let mut adj = deckcraft_model::style::PictureAdjust::default(); + deckcraft_engine::cmd::recolor::apply(&mut adj, id); + let r = r.shrink(3.0); + let n = 8; + for k in 0..n { + let t = k as f64 / (n - 1) as f64; + // A colourful ramp, so "No Recolor" shows colour and the presets show their mapping. + let src = [t * 255.0, (0.4 + 0.6 * t) * 200.0, (1.0 - t) * 220.0]; + let y = 0.299 * src[0] + 0.587 * src[1] + 0.114 * src[2]; + let mut rgb = if adj.grayscale { [y; 3] } else { src }; + if adj.brightness != 0.0 || adj.contrast != 0.0 { + rgb = rgb.map(|v| (v - 127.5) * (1.0 + adj.contrast) + 127.5 + adj.brightness * 255.0); + } + if let Some((d, l)) = &adj.duotone { + let (d, l) = (d.resolve(sc, None), l.resolve(sc, None)); + let u = y / 255.0; + rgb = + [d.r as f64 + (l.r as f64 - d.r as f64) * u, d.g as f64 + (l.g as f64 - d.g as f64) * u, d.b as f64 + (l.b as f64 - d.b as f64) * u]; + } + if let Some(th) = adj.bilevel { + rgb = [if y >= th * 255.0 { 255.0 } else { 0.0 }; 3]; + } + let c = Color32::from_rgb(rgb[0].clamp(0.0, 255.0) as u8, rgb[1].clamp(0.0, 255.0) as u8, rgb[2].clamp(0.0, 255.0) as u8); + let w = r.width() / n as f32; + p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); + } + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); +} + +/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or +/// all, delete cropped areas. The choices last for the session. +fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { + let id = egui::Id::unique("compress_pictures_opts"); + let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); + ui.label(egui::RichText::new("Compression options").strong()); + ui.checkbox(&mut only_selected, "Apply only to this picture"); + ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures"); + ui.separator(); + ui.label(egui::RichText::new("Resolution").strong()); + for (v, label) in [ + (330, "HD (330 ppi): good quality for high-definition displays"), + (220, "Print (220 ppi): excellent quality on most printers and screens"), + (150, "Web (150 ppi): good for web pages and projectors"), + (96, "E-mail (96 ppi): minimise document size for sharing"), + ] { + ui.radio_value(&mut ppi, v, label); + } + ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); + ui.separator(); + if ui.button("Compress").clicked() { + let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); + match app.session.execute("picture.compress", &p) { + Ok(r) => { + let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024; + let msg = match r["pictures"].as_u64().unwrap_or(0) { + 0 => "Pictures are already at or below that resolution".to_string(), + n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])), + }; + app.set_status(msg); + } + Err(e) => app.set_status(e.to_string()), + } + ui.close(); + } +} diff --git a/crates/ui-egui/src/ribbon/playback.rs b/crates/ui-egui/src/ribbon/playback.rs new file mode 100644 index 000000000..9322c76d9 --- /dev/null +++ b/crates/ui-egui/src/ribbon/playback.rs @@ -0,0 +1,55 @@ +use super::*; + +pub(super) fn playback(app: &mut SlideApp, ui: &mut Ui) { + let m = app + .session + .active() + .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Media(m) = &s.kind { Some(m.clone()) } else { None })); + group(ui, |ui| { + let playing = app.session.active().and_then(|d| d.selection.shapes.first().copied()).is_some_and(|id| app.media.is_playing(id)); + if big_button(ui, if playing { Icon::Pause } else { Icon::Play }, if playing { "Pause" } else { "Play" }, m.is_some()).clicked() { + run(app, "media.toggle", json!({})); + } + big(app, ui, Icon::TrimMedia, "Trim", "app.dialog", json!({"id": "trim"})); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut auto = m.as_ref().is_some_and(|x| x.autoplay); + if ui.checkbox(&mut auto, "Start Automatically").changed() { + run(app, "media.options", json!({"autoplay": auto})); + } + let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); + if ui.checkbox(&mut across, "Play Across Slides").changed() { + run(app, "media.options", json!({"acrossSlides": across})); + } + let mut lp = m.as_ref().is_some_and(|x| x.loop_play); + if ui.checkbox(&mut lp, "Loop until Stopped").changed() { + run(app, "media.options", json!({"loop": lp})); + } + }); + rows(ui, |ui| { + let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); + if ui.checkbox(&mut hide, "Hide During Show").changed() { + run(app, "media.options", json!({"hide": hide})); + } + let mut rw = m.as_ref().is_some_and(|x| x.rewind); + if ui.checkbox(&mut rw, "Rewind after Playing").changed() { + run(app, "media.options", json!({"rewind": rw})); + } + let mut fs = m.as_ref().is_some_and(|x| x.full_screen); + if ui.checkbox(&mut fs, "Play Full Screen").changed() { + run(app, "media.options", json!({"fullScreen": fs})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Volume, "Volume", m.is_some()); + egui::Popup::menu(&r).show(|ui| { + for (l, v) in [("Low", 0.25), ("Medium", 0.5), ("High", 1.0), ("Mute", 0.0)] { + if ui.button(l).clicked() { + run(app, "media.options", json!({"volume": v})); + } + } + }); + }); +} diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs new file mode 100644 index 000000000..70bf04d6b --- /dev/null +++ b/crates/ui-egui/src/ribbon/review.rs @@ -0,0 +1,58 @@ +use super::*; + +fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { + let (icon, label, id) = if forward { (Icon::NextComment, "Next", "comment.next") } else { (Icon::PrevComment, "Previous", "comment.prev") }; + if small_button(ui, icon, label, enabled(app, id), false).on_hover_text(tip(id, label)).clicked() { + let params = match app.ui.comment_focus { + Some((slide, index)) if app.session.active().is_some_and(|d| d.selection.slide == slide) => json!({"slide": slide, "index": index}), + _ => json!({}), + }; + if let Ok(v) = app.run(id, params) + && let (Some(slide), Some(index)) = (v.get("slide").and_then(Value::as_u64), v.get("index").and_then(Value::as_u64)) + && let (Ok(slide), Ok(index)) = (usize::try_from(slide), usize::try_from(index)) + { + app.ui.comment_focus = Some((slide, index)); + app.ui.pane = Some("comments".into()); + } + } +} + +pub(super) fn review(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Translate, "Translate", false); + let _ = r.on_hover_text("Translation needs a language model (not bundled)"); + big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); + }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); + group(ui, |ui| { + big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); + rows(ui, |ui| { + comment_nav(app, ui, false); + comment_nav(app, ui, true); + }); + big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); + let show = app.ui.pane.as_deref() == Some("comments"); + if big_button_ex(ui, Icon::ShowComments, "Show\nComments", true, false, show).0.clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/shape_format.rs b/crates/ui-egui/src/ribbon/shape_format.rs new file mode 100644 index 000000000..c80335521 --- /dev/null +++ b/crates/ui-egui/src/ribbon/shape_format.rs @@ -0,0 +1,266 @@ +use super::arrange::{arrange_group, size_group}; +use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery}; +use super::*; + +// ---------- contextual tabs ---------- + +/// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. +fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { + let current = selected_warp(app); + ui.label(egui::RichText::new("Transform").font(theme::bold(12.0))); + ui.label("Follow Path"); + let mut picked = false; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| deckcraft_geom::warp::is_follow(p.id)) { + if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { + run(app, "format.textWarp", json!({"preset": p.id})); + picked = true; + } + } + }); + ui.label("Warp"); + egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + if warp_tile(ui, "", "No Transform", current.is_none()) { + run(app, "format.textWarp", json!({"preset": "none"})); + picked = true; + } + for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| !deckcraft_geom::warp::is_follow(p.id)) { + if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { + run(app, "format.textWarp", json!({"preset": p.id})); + picked = true; + } + } + }); + }); + if picked { + ui.close(); + } +} + +fn selected_warp(app: &SlideApp) -> Option { + let doc = app.session.active()?; + let id = doc.selection.text.as_ref().map(|t| t.shape).or_else(|| doc.selection.shapes.first().copied())?; + doc.shape(id)?.text.as_ref()?.body.warp.clone() +} + +fn warp_tile(ui: &mut Ui, preset: &str, label: &str, selected: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 28.0), Sense::click()); + if selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.selected); + } else if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + paint_warp_icon(ui.painter(), rect.shrink(4.0), (!preset.is_empty()).then_some(preset), t.text); + resp.on_hover_text(label).clicked() +} + +/// The preset's guides, or two straight lines for No Transform. +fn paint_warp_icon(p: &egui::Painter, r: Rect, name: Option<&str>, color: Color32) { + let Some(name) = name else { + let y0 = r.min.y + r.height() * 0.32; + let y1 = r.min.y + r.height() * 0.68; + p.line_segment([pos2(r.min.x, y0), pos2(r.max.x, y0)], Stroke::new(1.0, color)); + p.line_segment([pos2(r.min.x, y1), pos2(r.max.x, y1)], Stroke::new(1.0, color)); + return; + }; + let rect = deckcraft_geom::Rect::new((r.min.x + 1.0) as f64, (r.min.y + 1.0) as f64, (r.max.x - 1.0) as f64, (r.max.y - 1.0) as f64); + let Some(w) = deckcraft_geom::warp::warp(name, &[], rect) else { return }; + for band in &w.bands { + match band { + deckcraft_geom::warp::Band::Fill { top, bottom } => { + stroke_guide(p, top, color); + stroke_guide(p, bottom, color); + } + deckcraft_geom::warp::Band::Follow { path, .. } => stroke_guide(p, path, color), + } + } +} + +fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color32) { + let len = g.length(); + if !(len.is_finite() && len > 1.0) { + return; + } + let n = 20i32; + let mut pts = Vec::with_capacity((n + 1) as usize); + for i in 0..=n { + let (q, _) = g.at(len * f64::from(i) / f64::from(n)); + if q.x.is_finite() && q.y.is_finite() { + pts.push(pos2(q.x as f32, q.y as f32)); + } + } + if pts.len() >= 2 { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: pts, + closed: false, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.0, color).into(), + })); + } +} + +/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls. +pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let sel = enabled(app, "shape.fill"); + group(ui, |ui| { + let r = big_button(ui, Icon::Shapes, "Shapes", true); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); + let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); + egui::Popup::menu(&r).show(|ui| { + ui.label("Change Shape"); + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + ui.separator(); + if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { + run(app, "shape.editPoints", json!({"on": true})); + ui.close(); + } + ui.separator(); + smooth_corners_row(app, ui); + }); + let can_merge = enabled(app, "shape.merge"); + let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); + egui::Popup::menu(&r).show(|ui| { + for (op, label) in deckcraft_engine::cmd::merge::OPS { + if ui.button(label).clicked() { + run(app, "shape.merge", json!({"op": op})); + ui.close(); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::QuickStyles, "Shape\nStyles", sel); + egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); + rows(ui, |ui| { + let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); + if m.clicked() { + let c = app.last_fill.clone(); + run(app, "shape.fill", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => { + app.last_fill = cref_param(&c); + run(app, "shape.fill", json!({"color": cref_param(&c)})); + } + None => run(app, "shape.fill", json!({"none": true})), + } + } + if ui.button("Gradient…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); + ui.close(); + } + }); + } + let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if m.clicked() { + let c = app.last_outline.clone(); + run(app, "shape.line", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); + } + let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); + } + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_color_popup(&main, &arrow).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "format.color", json!({"color": cref_param(&c)})); + } + if let Some(sh) = app.session.active().and_then(|d| d.selected_shapes().first().map(|s| (*s).clone())) { + ui.separator(); + crate::panes::text_transparency_slider(app, ui, &sh); + } + }); + } + let (main, arrow) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_color_popup(&main, &arrow).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))})); + } + }); + } + let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); + egui::Popup::menu(&r).width(312.0).show(|ui| { + if ui.button("Shadow").clicked() { + run(app, "format.textShadow", json!({})); + ui.close(); + } + ui.separator(); + transform_gallery(app, ui); + }); + }); + }); + group(ui, |ui| { + big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); + }); + arrange_group(app, ui); + size_group(app, ui); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Format\nPane", "view.pane", json!({"pane": "format"})); + }); +} + +/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a +/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui +/// memory, per shape) so later moves re-smooth the same corners. +fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { + let Some((id, radius)) = app.session.active().and_then(|d| { + let id = *d.selection.shapes.first()?; + let sh = d.shape(id)?; + let x = sh.xfrm?; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { + Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) + } + deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + Some((id, own)) + }) else { + return; + }; + let key = egui::Id::unique(("deckcraft-smooth-corners", id.0)); + let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); + if let Some(r) = radius { + state.0 = r; + } + let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); + ui.horizontal(|ui| { + let label = ui.label("Smooth Corners"); + let mut pct = state.1; + let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); + let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; + if resp.changed() { + state.1 = pct; + ui.data_mut(|d| d.insert_temp(key, state)); + } + // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). + if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/slide_show.rs b/crates/ui-egui/src/ribbon/slide_show.rs new file mode 100644 index 000000000..8542f547f --- /dev/null +++ b/crates/ui-egui/src/ribbon/slide_show.rs @@ -0,0 +1,78 @@ +use super::*; + +// ---------- Slide Show / Record / Review / View ---------- + +pub(super) fn slide_show(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { + app.start_show(app.show_start_index(), false); + } + if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + } + let on = app.ui.presenter_view; + if big_button_ex(ui, Icon::PresenterView, "Presenter\nView", true, false, on).0.clicked() { + app.ui.presenter_view = !on; + } + big(app, ui, Icon::CustomShow, "Custom\nShow", "app.dialog", json!({"id": "customShow"})); + }); + group(ui, |ui| { + big(app, ui, Icon::SetUpShow, "Set Up\nSlide Show", "app.dialog", json!({"id": "setupShow"})); + let hidden = app.session.active().and_then(|d| d.current_slide().map(|s| s.hidden)).unwrap_or(false); + if big_button_ex(ui, Icon::HideSlide, "Hide\nSlide", true, false, hidden).0.clicked() { + run(app, "slide.hide", json!({})); + } + }); + group(ui, |ui| { + if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { + app.start_show(app.show_start_index(), false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + let r = big_button(ui, Icon::Record, "Record", false); + let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)"); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); + if ui.checkbox(&mut use_t, "Use Timings").changed() { + run(app, "show.setup", json!({"useTimings": use_t})); + } + let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); + if ui.checkbox(&mut narr, "Play Narrations").changed() { + run(app, "show.setup", json!({"noNarration": !narr})); + } + let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); + if ui.checkbox(&mut mc, "Show Media Controls").changed() { + run(app, "show.setup", json!({"showMediaControls": mc})); + } + }); + }); +} + +pub(super) fn record(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::Cameo, "Cameo", false); + let _ = r.on_hover_text("Camera capture is not available yet"); + if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { + app.start_show(app.show_start_index(), false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + if big_button(ui, Icon::PlayFromCurrent, "From\nCurrent Slide", true).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + }); + group(ui, |ui| { + if big_button(ui, Icon::Help, "Learn\nMore", true).clicked() { + app.open_url(deckcraft_engine::links::APP_PAGE); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/smartart.rs b/crates/ui-egui/src/ribbon/smartart.rs new file mode 100644 index 000000000..b8b38fbbe --- /dev/null +++ b/crates/ui-egui/src/ribbon/smartart.rs @@ -0,0 +1,103 @@ +use super::arrange::arrange_group; +use super::galleries::smartart_gallery; +use super::*; + +/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. +pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { + let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); + let row = app.ui.smartart_row; + let (gid, n, kind, style, colors) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), + None => (None, 0, String::new(), String::new(), String::new()), + }; + let at = row.min(n.saturating_sub(1)); + group(ui, |ui| { + if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { + run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); + } + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (label, pos) in [ + ("Add Shape After", "after"), + ("Add Shape Before", "before"), + ("Add Shape Above", "above"), + ("Add Shape Below", "below"), + ("Add Assistant", "assistant"), + ] { + if ui.button(label).clicked() { + let index = if n == 0 { Value::Null } else { json!(at) }; + run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + } + } + }); + } + if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { + run(app, "smartArt.promote", json!({"id": gid, "index": at})); + } + if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { + run(app, "smartArt.demote", json!({"id": gid, "index": at})); + } + }); + let pictures = kind.starts_with("picture"); + if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) + .on_hover_text("Put a picture in the selected node's picture placeholder") + .clicked() + { + run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); + } + }); + group(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); + let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); + }); + } + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + if ui.button("Convert to Text").clicked() { + run(app, "smartArt.toText", json!({"id": gid})); + } + if ui.button("Convert to Shapes").clicked() { + run(app, "smartArt.toShapes", json!({"id": gid})); + } + }); + } + }); + group(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + if matches!(k, "range23" | "accent1" | "accent1Grad") { + ui.separator(); + } + let on = colors == k || (colors.is_empty() && k == "colorful"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.colors", json!({"id": gid, "colors": k})); + } + } + }); + } + let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + if k == "polished" { + ui.separator(); + ui.label("3-D"); + } + let on = style == k || (style.is_empty() && k == "flat"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.style", json!({"id": gid, "style": k})); + } + } + }); + } + }); + arrange_group(app, ui); +} diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs new file mode 100644 index 000000000..b2c0fde85 --- /dev/null +++ b/crates/ui-egui/src/ribbon/table.rs @@ -0,0 +1,236 @@ +use super::arrange::arrange_group; +use super::galleries::cref_param; +use super::*; + +pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { + let tbl = app + .session + .active() + .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Table(t) = &s.kind { Some(t.clone()) } else { None })); + group(ui, |ui| { + rows(ui, |ui| { + let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); + ui.horizontal(|ui| { + let mut a = flags.0; + if ui.checkbox(&mut a, "Header Row").changed() { + run(app, "table.options", json!({"headerRow": a})); + } + let mut d = flags.3; + if ui.checkbox(&mut d, "First Column").changed() { + run(app, "table.options", json!({"firstColumn": d})); + } + }); + ui.horizontal(|ui| { + let mut b = flags.1; + if ui.checkbox(&mut b, "Total Row").changed() { + run(app, "table.options", json!({"totalRow": b})); + } + let mut e = flags.4; + if ui.checkbox(&mut e, "Last Column").changed() { + run(app, "table.options", json!({"lastColumn": e})); + } + }); + ui.horizontal(|ui| { + let mut c = flags.2; + if ui.checkbox(&mut c, "Banded Rows").changed() { + run(app, "table.options", json!({"bandedRows": c})); + } + let mut f = flags.5; + if ui.checkbox(&mut f, "Banded Columns").changed() { + run(app, "table.options", json!({"bandedColumns": f})); + } + }); + }); + }); + group(ui, |ui| { + let styles = deckcraft_model::table::table_styles(); + let quick: Vec<_> = styles.iter().filter(|(id, _)| id.starts_with("medium2") || id == "none" || id == "grid").take(8).collect(); + let cur = tbl.as_ref().map(|t| t.style.clone()).unwrap_or_default(); + let sel = quick.iter().position(|(id, _)| *id == cur); + let sc = scheme(app); + if let Some(i) = widgets::gallery( + ui, + quick.len(), + vec2(48.0, 40.0), + sel, + |p, r, i| { + if let Some((id, _)) = quick.get(i) { + paint_table_style(p, r, id, &sc); + } + }, + |i| quick.get(i).map(|x| x.1.clone()).unwrap_or_default(), + ) && let Some((id, _)) = quick.get(i) + { + run(app, "table.style", json!({"style": id})); + } + let more = widgets::drop_button(ui, "", vec2(14.0, 40.0), true); + egui::Popup::menu(&more).width(360.0).show(|ui| { + ui.horizontal_wrapped(|ui| { + for (id, label) in &styles { + let (r, resp) = ui.allocate_exact_size(vec2(48.0, 36.0), Sense::click()); + paint_table_style(ui.painter(), r.shrink(2.0), id, &sc); + if resp.on_hover_text(label).clicked() { + run(app, "table.style", json!({"style": id})); + ui.close(); + } + } + }); + }); + }); + group(ui, |ui| { + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::Shading, "Shading", Color32::from_rgb(0x2E, 0x6F, 0xD8), true); + if m.clicked() { + run(app, "table.cellFill", json!({"color": "accent1"})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})), + None => run(app, "table.cellFill", json!({"none": true})), + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Borders, "Borders", true); + egui::Popup::menu(&r).show(|ui| { + let pen = app.table_pen.clone(); + for (label, which) in [ + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ("Top Border", "top"), + ("Bottom Border", "bottom"), + ("Left Border", "left"), + ("Right Border", "right"), + ("Inside Horizontal Border", "insideH"), + ("Inside Vertical Border", "insideV"), + ("Diagonal Down Border", "diagDown"), + ("Diagonal Up Border", "diagUp"), + ] { + let mut p = pen.clone(); + p["which"] = json!(which); + menu_item(app, ui, Icon::Borders, label, "table.borders", p); + } + ui.separator(); + ui.menu_button("Pen Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); + if ui.radio(cur, format!("{w} pt")).clicked() { + app.table_pen["width"] = json!(w); + } + } + }); + ui.menu_button("Pen Style", |ui| { + for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { + let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; + if ui.radio(cur, l).clicked() { + app.table_pen["dash"] = json!(d); + } + } + }); + ui.menu_button("Pen Color", |ui| { + let sc = scheme(app); + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + app.table_pen["color"] = cref_param(&c); + } + }); + }); + }); + // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. + group(ui, |ui| { + use deckcraft_engine::ToolKind; + use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; + let cur = app.session.tool.kind.clone(); + for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { + let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); + } + } + }); +} + +pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { + let slot = match id.rsplit('-').next().unwrap_or("accent1") { + "accent2" => deckcraft_color::SchemeSlot::Accent2, + "accent3" => deckcraft_color::SchemeSlot::Accent3, + "accent4" => deckcraft_color::SchemeSlot::Accent4, + "accent5" => deckcraft_color::SchemeSlot::Accent5, + "accent6" => deckcraft_color::SchemeSlot::Accent6, + "tx1" => deckcraft_color::SchemeSlot::Dk1, + _ => deckcraft_color::SchemeSlot::Accent1, + }; + let a = theme::to_color32(sc.get(slot)); + let kind = id.split('-').next().unwrap_or(""); + for row in 0..4 { + let y = r.min.y + row as f32 * r.height() / 4.0; + let rr = Rect::from_min_size(pos2(r.min.x, y), vec2(r.width(), r.height() / 4.0)); + let c = match (kind, row) { + ("none", _) | ("grid", _) => Color32::WHITE, + ("light1", 0) | ("light2", _) => Color32::WHITE, + ("dark1", 0) => Color32::BLACK, + ("dark1", _) => a.lerp_to_gamma(Color32::BLACK, 0.3), + (_, 0) => a, + (_, k) if k % 2 == 1 => a.lerp_to_gamma(Color32::WHITE, 0.6), + _ => a.lerp_to_gamma(Color32::WHITE, 0.8), + }; + p.rect_filled(rr, CornerRadius::ZERO, c); + p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(0.5, if kind == "grid" { Color32::GRAY } else { Color32::WHITE })); + } + if kind == "grid" || kind == "light2" { + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.8, if kind == "grid" { Color32::GRAY } else { a }), egui::StrokeKind::Inside); + } +} + +pub(super) fn table_layout(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::DeleteTable, "Delete", true); + egui::Popup::menu(&r).show(|ui| { + menu_item(app, ui, Icon::DeleteTable, "Delete Columns", "table.deleteColumn", json!({})); + menu_item(app, ui, Icon::DeleteTable, "Delete Rows", "table.deleteRow", json!({})); + menu_item(app, ui, Icon::DeleteTable, "Delete Table", "edit.delete", json!({})); + }); + big(app, ui, Icon::InsertAbove, "Insert\nAbove", "table.insertRowAbove", json!({})); + big(app, ui, Icon::InsertBelow, "Insert\nBelow", "table.insertRowBelow", json!({})); + big(app, ui, Icon::InsertLeft, "Insert\nLeft", "table.insertColumnLeft", json!({})); + big(app, ui, Icon::InsertRight, "Insert\nRight", "table.insertColumnRight", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::MergeCells, "Merge\nCells", "table.merge", json!({})); + big(app, ui, Icon::SplitCells, "Split\nCells", "table.split", json!({})); + }); + group(ui, |ui| { + rows(ui, |ui| { + small(app, ui, Icon::DistributeV, "Distribute Rows", "table.distributeRows", json!({})); + small(app, ui, Icon::DistributeH, "Distribute Columns", "table.distributeColumns", json!({})); + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", false); + toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", false); + toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", false); + }); + ui.horizontal(|ui| { + for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { + if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { + run(app, "table.cellAlign", json!({"anchor": a})); + } + } + ui.menu_button("Cell Margins", |ui| { + for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { + if ui.button(l).clicked() { + run(app, "table.cellMargins", json!({"preset": preset})); + ui.close(); + } + } + }); + }); + }); + }); + arrange_group(app, ui); +} diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs new file mode 100644 index 000000000..c8bdd351f --- /dev/null +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -0,0 +1,95 @@ +use super::*; + +pub const TITLE_H: f32 = 38.0; + +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, 16 pt from the top of the window on macOS 27. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is the band they are centred +/// in, with the controls beside them (#71). Up to macOS 15 the lights are smaller and centred 14 pt +/// from the top, about 2 pt above this bar's centre. +pub const MAC_TITLE_BAR: f32 = 32.0; + +/// The traffic lights as AppKit lays them out, measured on macOS 27: button diameter, +/// centre-to-centre pitch and the first button's left edge. +pub(super) const MAC_LIGHT: f32 = 14.0; + +pub(super) const MAC_LIGHT_PITCH: f32 = 23.0; + +pub(super) const MAC_LIGHTS_LEFT: f32 = 9.0; + +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + +/// The title bar's height, the left edge of its content and the height of its tallest control. +/// With the integrated title bar (macOS) the bar is shorter, so the controls shrink to keep a +/// margin above and below them. +pub(super) fn title_geometry(integrated: bool) -> (f32, f32, f32) { + if integrated { (MAC_TITLE_BAR, MAC_CONTENT_LEFT, 22.0) } else { (TITLE_H, 10.0, 26.0) } +} + +/// The window's title row: traffic-light space, quick access, centred title, actions. +pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (height, left, control_h) = title_geometry(app.integrated_titlebar); + egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + let rect = ui.max_rect(); + // Dragging the empty title bar moves the window. + let drag = ui.interact(rect, ui.scope_id().with("titledrag"), Sense::click_and_drag()); + // The drag surface is not a button: a full-width Button would be announced as one. + drag.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Window, true, "Window title bar")); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.min.x + left, rect.min.y), pos2(rect.min.x + 420.0, rect.max.y))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.spacing_mut().item_spacing.x = 2.0; + let has_doc = app.session.active().is_some(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + crate::qat::show(app, &mut child); + let title = + app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); + // Right side. + let mut right = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.max.x - 320.0, rect.min.y), pos2(rect.max.x - 10.0, rect.max.y))) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + right.spacing_mut().item_spacing.x = 6.0; + if icon_toggle(&mut right, Icon::Search, "Search commands (⇧⌘P)", true, false).clicked() { + app.palette = Some((String::new(), 0)); + } + let share = right.add( + egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) + .fill(t.accent) + .corner_radius(control_h / 2.0) + .min_size(vec2(84.0, control_h)), + ); + if share.clicked() && has_doc { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, false); + } + if icon_toggle(&mut right, Icon::CommentsBubble, "Comments", has_doc, app.ui.pane.as_deref() == Some("comments")).clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + if icon_toggle( + &mut right, + if app.ui.brightness == theme::Brightness::Dark { Icon::Sparkle } else { Icon::Palette }, + "Light/Dark appearance", + true, + false, + ) + .clicked() + { + let _ = app.run("view.dark", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs new file mode 100644 index 000000000..3417e657c --- /dev/null +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -0,0 +1,239 @@ +use super::animations::marked_options; +use super::*; + +// ---------- Transitions ---------- + +/// Render transition preview, gallery, options, sound, and timing controls for the current +/// slide. +pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { + let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); + let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); + group(ui, |ui| { + if big_button(ui, Icon::Preview, "Preview", true).clicked() { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, true); + if let Some(s) = app.show.as_mut() { + s.preview_only = true; + } + } + }); + group(ui, |ui| { + let list = deckcraft_model::anim::TRANSITIONS; + let shown: Vec<_> = list.iter().take(12).collect(); + let sel = shown.iter().position(|x| x.0 == kind); + if let Some(i) = widgets::gallery( + ui, + shown.len(), + vec2(54.0, 56.0), + sel, + |p, r, i| paint_transition_tile(p, r, shown.get(i).map(|x| (x.0, x.1)).unwrap_or(("", ""))), + |i| shown.get(i).map(|x| x.1.to_string()).unwrap_or_default(), + ) && let Some(x) = shown.get(i) + { + run(app, "transition.set", json!({"kind": x.0})); + } + let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), true); + egui::Popup::menu(&more).width(480.0).show(|ui| { + for cat in ["Subtle", "Exciting", "Dynamic Content"] { + ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for x in list.iter().filter(|x| x.2 == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 58.0), Sense::click()); + if resp.hovered() || x.0 == kind { + ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); + } + paint_transition_tile(ui.painter(), r.shrink(3.0), (x.0, x.1)); + if resp.clicked() { + run(app, "transition.set", json!({"kind": x.0})); + ui.close(); + } + } + }); + } + }); + }); + group(ui, |ui| { + let selected = cur.as_ref().map(|t| t.option.as_str()); + let rows = transition_effect_rows(&kind, selected); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); + egui::Popup::menu(&r).show(|ui| { + for &(o, on) in &rows { + if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + run(app, "transition.options", json!({"option": o})); + ui.close(); + } + } + }); + }); + group(ui, |ui| { + // Sound in its own column, so the timing column keeps its three rows. + rows(ui, |ui| { + ui.horizontal(|ui| { + let field = ui.label("Sound:"); + let stop = cur.as_ref().is_some_and(|t| t.stop_sound); + let name = cur + .as_ref() + .and_then(|t| t.sound) + .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) + .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); + let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { + let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); + if ui.selectable_label(none, "[No Sound]").clicked() { + run(app, "transition.sound", json!({"none": true})); + } + if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { + run(app, "transition.sound", json!({"stopPrevious": true})); + } + ui.separator(); + for (id, label) in deckcraft_engine::sounds::BUILTIN { + if ui.button(label).clicked() { + let lp = cur.as_ref().is_some_and(|t| t.sound_loop); + run(app, "transition.sound", json!({"builtin": id, "loop": lp})); + } + } + ui.separator(); + let can_pick = app.services.pick_open.is_some(); + let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); + if r.clicked() { + app.pick_transition_sound(); + } + r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + ui.separator(); + let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); + let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); + if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { + run(app, "transition.sound", json!({"loop": lp})); + } + }); + let _ = combo.response.labelled_by(field.id); + }); + }); + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Duration:"); + let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); + if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { + run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + ui.horizontal(|ui| { + let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click); + if ui.checkbox(&mut click, "On Mouse Click").changed() { + run(app, "transition.timing", json!({"onClick": click})); + } + }); + ui.horizontal(|ui| { + let after = cur.as_ref().and_then(|t| t.advance_after_ms); + let mut on = after.is_some(); + if ui.checkbox(&mut on, "After:").changed() { + run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); + } + if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { + run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + }); + big(app, ui, Icon::ApplyToAll, "Apply\nTo All", "transition.applyAll", json!({})); + }); +} + +pub(super) fn option_label(o: &str) -> String { + match o { + "l" => "From Left".into(), + "r" => "From Right".into(), + "u" => "From Top".into(), + "d" => "From Bottom".into(), + "horz" => "Horizontal".into(), + "vert" => "Vertical".into(), + "in" => "In".into(), + "out" => "Out".into(), + other => { + let mut s = String::new(); + for (i, c) in other.chars().enumerate() { + if i == 0 { + s.extend(c.to_uppercase()); + } else if c.is_uppercase() { + s.push(' '); + s.push(c); + } else { + s.push(c); + } + } + s + } + } +} + +pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { + let t = Tokens::get(p.ctx()); + let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); + let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); + let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); + p.rect_filled(pic, CornerRadius::same(2), light); + match id { + "none" => { + p.rect_filled(pic, CornerRadius::same(2), Color32::from_rgb(0xE6, 0xEC, 0xF5)); + } + "fade" | "dissolve" | "flash" => { + p.rect_filled(pic, CornerRadius::same(2), blue.gamma_multiply(0.5)); + } + "push" | "cover" | "uncover" | "pan" | "conveyor" => { + p.rect_filled(Rect::from_min_max(pos2(pic.center().x, pic.min.y), pic.max), CornerRadius::same(2), blue); + p.arrow(pos2(pic.center().x + 10.0, pic.center().y), vec2(-12.0, 0.0), Stroke::new(1.5, Color32::WHITE)); + } + "wipe" | "reveal" => { + p.rect_filled(Rect::from_min_max(pic.min, pos2(pic.center().x, pic.max.y)), CornerRadius::same(2), blue); + } + "split" | "doors" | "window" | "curtains" => { + p.rect_filled( + Rect::from_min_max(pos2(pic.min.x + pic.width() * 0.3, pic.min.y), pos2(pic.max.x - pic.width() * 0.3, pic.max.y)), + CornerRadius::ZERO, + blue, + ); + } + "randomBar" | "blinds" | "comb" | "shred" => { + for k in 0..5 { + let x = pic.min.x + k as f32 * pic.width() / 5.0; + p.rect_filled(Rect::from_min_size(pos2(x, pic.min.y), vec2(pic.width() / 10.0, pic.height())), CornerRadius::ZERO, blue); + } + } + "checker" | "honeycomb" | "glitter" | "fracture" => { + for k in 0..12 { + if k % 2 == (k / 4) % 2 { + let x = pic.min.x + (k % 4) as f32 * pic.width() / 4.0; + let y = pic.min.y + (k / 4) as f32 * pic.height() / 3.0; + p.rect_filled(Rect::from_min_size(pos2(x, y), vec2(pic.width() / 4.0, pic.height() / 3.0)), CornerRadius::ZERO, blue); + } + } + } + "shape" | "zoom" | "ripple" | "vortex" | "clock" => { + p.circle_filled(pic.center(), pic.height() * 0.38, blue); + } + "morph" => { + p.circle_filled(pos2(pic.min.x + pic.width() * 0.3, pic.center().y), pic.height() * 0.25, blue); + p.rect_filled( + Rect::from_center_size(pos2(pic.max.x - pic.width() * 0.3, pic.center().y), vec2(pic.height() * 0.45, pic.height() * 0.45)), + CornerRadius::same(2), + blue.gamma_multiply(0.7), + ); + } + _ => { + // Fake 3-D: a tilted card. + let pts = vec![ + pos2(pic.min.x + 6.0, pic.min.y + 4.0), + pos2(pic.max.x - 3.0, pic.min.y + 1.0), + pos2(pic.max.x - 3.0, pic.max.y - 1.0), + pos2(pic.min.x + 6.0, pic.max.y - 4.0), + ]; + p.add(egui::Shape::convex_polygon(pts, blue, Stroke::NONE)); + } + } + p.rect_stroke(pic, CornerRadius::same(2), Stroke::new(1.0, Color32::from_rgb(0x9B, 0xB4, 0xDB)), egui::StrokeKind::Inside); + p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, label, theme::font(10.0), t.text); +} + +/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. +pub(super) fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + marked_options(opts, current) +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs new file mode 100644 index 000000000..8231f9a69 --- /dev/null +++ b/crates/ui-egui/src/ribbon/view.rs @@ -0,0 +1,155 @@ +use super::*; + +pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { + use crate::ViewMode; + group(ui, |ui| { + for (icon, label, id, mode) in [ + (Icon::ViewNormal, "Normal", "view.normal", Some(ViewMode::Normal)), + (Icon::ViewOutline, "Outline\nView", "view.outline", Some(ViewMode::Outline)), + (Icon::ViewSorter, "Slide\nSorter", "view.sorter", Some(ViewMode::Sorter)), + (Icon::ViewNotesPage, "Notes\nPage", "view.notesPage", Some(ViewMode::NotesPage)), + (Icon::ViewReading, "Reading\nView", "view.reading", None), + ] { + let sel = mode.is_some_and(|m| app.ui.view == m); + if big_button_ex(ui, icon, label, true, false, sel).0.clicked() { + let _ = app.run(id, json!({})); + } + } + }); + group(ui, |ui| { + let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); + if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() { + let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); + } + let in_handout = app.session.active().is_some_and(|d| d.selection.target == Target::Handout); + if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { + let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); + } + let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); + if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { + let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); + } + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut r = app.ui.ruler; + if ui.checkbox(&mut r, "Ruler").changed() { + app.ui.ruler = r; + } + let mut g = app.ui.gridlines; + if ui.checkbox(&mut g, "Gridlines").changed() { + app.ui.gridlines = g; + } + let mut gd = app.ui.guides; + if ui.checkbox(&mut gd, "Guides").changed() { + app.ui.guides = gd; + } + }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } + let n = app.ui.notes; + if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { + app.ui.notes = !n; + } + }); + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false); + if main.on_hover_text("Zoom").clicked() { + let _ = app.run("view.zoomDialog", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + for pct in [400, 200, 150, 100, 75, 66, 50, 33] { + if ui.button(format!("{pct}%")).clicked() { + let _ = app.run("view.zoom", json!({"percent": pct})); + ui.close(); + } + } + if ui.button("Fit").clicked() { + let _ = app.run("view.fit", json!({})); + ui.close(); + } + }); + } + if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() { + app.ui.zoom = None; + } + }); + group(ui, |ui| { + let has = app.session.active().is_some(); + let on = app.doc_window.is_some(); + let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0; + if resp.on_hover_text("Open another window on this presentation").clicked() { + let _ = app.run("view.newWindow", json!({})); + } + }); + group(ui, |ui| { + let gs = app.ui.grayscale; + if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() { + app.ui.grayscale = !gs; + } + let dark = app.ui.brightness == theme::Brightness::Dark; + if big_button_ex(ui, Icon::Palette, "Dark\nMode", true, false, dark).0.clicked() { + let _ = app.run("view.dark", json!({})); + } + }); +} + +/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's +/// Insert Zoom dialogs do (a checklist of slides or sections). +pub(super) fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let id = egui::Id::unique("insert_zoom_menu"); + let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); + ui.horizontal(|ui| { + for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { + if ui.selectable_label(kind == k, label).clicked() && kind != k { + kind = k; + picked.clear(); + } + } + }); + ui.separator(); + let items: Vec = if kind == 1 { + doc.sections.iter().map(|s| s.name.clone()).collect() + } else { + doc.slides + .iter() + .enumerate() + .map(|(i, s)| { + let t = s.title(); + format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim()) + }) + .collect() + }; + if items.is_empty() { + ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); + } + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, label) in items.iter().enumerate() { + let mut on = picked.contains(&i); + if ui.checkbox(&mut on, label).changed() { + if on { + picked.push(i); + } else { + picked.retain(|x| *x != i); + } + } + } + }); + ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); + ui.separator(); + if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { + let mut v = picked.clone(); + v.sort_unstable(); + let p = match kind { + 1 => json!({"kind": "section", "sections": v}), + 2 => json!({"kind": "summary", "slides": v}), + _ => json!({"kind": "slide", "slides": v}), + }; + run(app, "insert.zoom", p); + ui.data_mut(|d| d.remove::<(u8, Vec)>(id)); + ui.close(); + } +} From ada66524a2ee1469b685e65d152c88cff19365bb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:42:27 +0000 Subject: [PATCH 44/90] refactor(ui-egui): split panels.rs into one file per pane under panels/ --- crates/ui-egui/src/panels.rs | 1455 +-------------------- crates/ui-egui/src/panels/animation.rs | 184 +++ crates/ui-egui/src/panels/background.rs | 195 +++ crates/ui-egui/src/panels/comments.rs | 166 +++ crates/ui-egui/src/panels/designer.rs | 94 ++ crates/ui-egui/src/panels/format_shape.rs | 676 ++++++++++ crates/ui-egui/src/panels/smartart.rs | 90 ++ crates/ui-egui/src/panels/thesaurus.rs | 36 + 8 files changed, 1461 insertions(+), 1435 deletions(-) create mode 100644 crates/ui-egui/src/panels/animation.rs create mode 100644 crates/ui-egui/src/panels/background.rs create mode 100644 crates/ui-egui/src/panels/comments.rs create mode 100644 crates/ui-egui/src/panels/designer.rs create mode 100644 crates/ui-egui/src/panels/format_shape.rs create mode 100644 crates/ui-egui/src/panels/smartart.rs create mode 100644 crates/ui-egui/src/panels/thesaurus.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5d46268a9..e31e5298d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -10,6 +10,25 @@ use crate::ribbon::{cref_param, paint_anim_tile}; use crate::theme::{self, Tokens}; use crate::{SlideApp, widgets}; +mod animation; +mod background; +mod comments; +mod designer; +mod format_shape; +mod smartart; +mod thesaurus; + +use animation::animation; +use background::background; +use comments::{comments, revisions}; +use designer::designer; +use format_shape::format_shape; +pub(crate) use format_shape::text_transparency_slider; +#[cfg(test)] +use format_shape::{fill_transparency_to_apply, solid_fill_transparency_percent}; +use smartart::smartart; +use thesaurus::thesaurus; + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(pane) = app.ui.pane.clone() else { return }; @@ -61,122 +80,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { }); } -/// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and -/// Shift+Tab demote and promote, Backspace in an empty line removes it. -fn smartart(app: &mut SlideApp, ui: &mut Ui) { - let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { - ui.label("Select a SmartArt graphic to edit its text."); - return; - }; - let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); - let focused_any = ui.ctx().memory(|m| m.focused().is_some()) || app.ui.smartart_focus.is_some(); - if app.ui.smartart_drafts.0 != gid.0 || app.ui.smartart_drafts.1.len() != texts.len() || !focused_any { - app.ui.smartart_drafts = (gid.0, texts.clone()); - } - let mut drafts = std::mem::take(&mut app.ui.smartart_drafts.1); - let mut action: Option<(&str, Value)> = None; - let mut commit: Option = None; - let mut focus_row: Option = None; - ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); - ui.add_space(4.0); - for (i, node) in d.nodes.iter().enumerate() { - let Some(draft) = drafts.get_mut(i) else { break }; - let id = egui::Id::unique(("smartart_line", gid.0, i)); - ui.horizontal(|ui| { - ui.add_space(16.0 * node.level as f32); - ui.label("•"); - let te = egui::TextEdit::singleline(draft).id(id).hint_text("SmartArt text").desired_width(f32::INFINITY); - let r = ui.add(te); - if app.ui.smartart_focus == Some(i) { - r.request_focus(); - app.ui.smartart_focus = None; - } - if r.gained_focus() || r.has_focus() { - app.ui.smartart_row = i; - } - if r.has_focus() { - let back = ui.input(|inp| inp.key_pressed(egui::Key::Backspace)); - if back && draft.is_empty() && texts.get(i).is_some_and(String::is_empty) && d.nodes.len() > 1 { - action = Some(("smartArt.removeNode", json!({"index": i}))); - focus_row = Some(i.saturating_sub(1)); - } - } - if r.lost_focus() { - let (tab, shift) = ui.input(|inp| (inp.key_pressed(egui::Key::Tab), inp.modifiers.shift)); - if tab { - action = Some((if shift { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": i}))); - focus_row = Some(i); - } else if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { - action = Some(("smartArt.addNode", json!({"index": i, "position": "after"}))); - focus_row = Some(i + 1); - } - commit = Some(i); - } - }); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - if ui.button("+ Add").clicked() { - action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); - } - if ui.button("Remove").clicked() && !d.nodes.is_empty() { - action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); - } - }); - // Commit a changed line before any structural edit, so the edit sees the new text. - if let Some(i) = commit - && drafts.get(i) != texts.get(i) - { - run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": drafts.get(i).cloned().unwrap_or_default()})); - } - if action.is_some() { - for (i, t) in drafts.iter().enumerate() { - if Some(i) != commit && texts.get(i) != Some(t) { - run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": t})); - } - } - } - app.ui.smartart_drafts.1 = drafts; - if let Some((cmd, mut p)) = action { - if let Some(o) = p.as_object_mut() { - o.insert("id".into(), json!(gid.0)); - } - if let Ok(r) = app.run(cmd, p) { - let row = r.get("index").and_then(Value::as_u64).map(|v| v as usize).or(focus_row).unwrap_or(0); - app.ui.smartart_row = row; - app.ui.smartart_drafts.0 = 0; - app.ui.smartart_focus = Some(row); - } - } -} - fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } -/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s -/// `transparency` parameter writes as an alpha transform. Anything else (no fill, a -/// gradient, a picture, a pattern) is 0. -fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 { - let Some(Fill::Solid { color }) = fill else { - return 0.0; - }; - ((1.0 - color.alpha()) * 100.0) as f32 -} - -/// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus -/// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill. -fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option { - if !shown_percent.is_finite() { - return None; - } - let shown = shown_percent.clamp(0.0, 100.0); - if (shown - seed_percent).abs() <= 0.01 { - return None; - } - Some(f64::from(shown) / 100.0) -} - fn scheme(app: &SlideApp) -> deckcraft_color::ColorScheme { app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| deckcraft_model::Theme::default().colors) } @@ -203,1300 +110,13 @@ fn color_button( }); } -/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. -fn format_shape(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { - ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); - return; - }; - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); - let uid = st.uid; - // Tabs as icons. - ui.horizontal(|ui| { - for (tab, icon, tip) in [ - ("fill", Icon::ShapeFill, "Fill & Line"), - ("effects", Icon::ShapeEffects, "Effects"), - ("size", Icon::SlideSize, "Size & Properties"), - ("text", Icon::TextBox, "Text Options"), - ] { - let on = app.ui.format_tab == tab; - if widgets::icon_toggle(ui, icon, tip, true, on).clicked() { - app.ui.format_tab = tab.into(); - } - } - if matches!(sh.kind, ShapeKind::Picture { .. }) - && widgets::icon_toggle(ui, Icon::Picture, "Picture", true, app.ui.format_tab == "picture").clicked() - { - app.ui.format_tab = "picture".into(); - } - }); - ui.separator(); - match app.ui.format_tab.as_str() { - "effects" => { - ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); - ui.horizontal_wrapped(|ui| { - for (l, v) in [ - ("None", "none"), - ("Outer", "outer"), - ("Bottom", "bottom"), - ("Center", "center"), - ("Inner", "inner"), - ("Perspective", "perspective"), - ] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"shadow": v})); - } - } - }); - let fx = sh.effects.clone().unwrap_or_default(); - let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); - if let Some(sw) = shadow { - color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); - fx_slider( - app, - ui, - ("shadow", sh.id.0, "t"), - "Transparency", - (1.0 - sw.color.alpha()) * 100.0, - 0.0..=100.0, - "%", - "shadowProps", - "transparency", - 0.01, - ); - fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); - fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "°", "shadowProps", "dir", 1.0); - fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); - ui.horizontal_wrapped(|ui| { - for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"reflection": v})); - } - } - }); - if let Some(r) = &fx.reflection { - fx_slider( - app, - ui, - ("refl", sh.id.0, "t"), - "Transparency", - (1.0 - r.start_alpha) * 100.0, - 0.0..=100.0, - "%", - "reflectionProps", - "transparency", - 0.01, - ); - fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); - fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); - fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); - let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); - fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); - if let Some(g) = &fx.glow { - color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); - fx_slider( - app, - ui, - ("glow", sh.id.0, "t"), - "Transparency", - (1.0 - g.color.alpha()) * 100.0, - 0.0..=100.0, - "%", - "glowProps", - "transparency", - 0.01, - ); - } - ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); - let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { - run(app, "shape.effects", json!({"softEdges": s})); - } - let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); - three_d_options(app, ui, sh.id.0, raw3d.as_deref()); - if ui.button("Reset Effects").clicked() { - run(app, "shape.effects", json!({"reset": true})); - } - } - "size" => { - let lock_id = egui::Id::unique("fmt-lock-aspect"); - let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); - let locked = lock; - let len = app.session.prefs.units; - let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| { - let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) }; - ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, suffix, 70.0) { - let mut p = json!({key: nv * unit}); - if cmd == "shape.resize" && locked { - p["lockAspect"] = json!(true); - } - run(app, cmd, p); - } - }); - }; - ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); - fields(ui, "Height", "fh", x.h, "h", "shape.resize", false); - fields(ui, "Width", "fw", x.w, "w", "shape.resize", false); - fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true); - if ui.checkbox(&mut lock, "Lock aspect ratio").changed() { - ui.data_mut(|d| d.insert_temp(lock_id, lock)); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); - fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false); - fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false); - ui.add_space(6.0); - ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); - let id = ui.scope_id().with(("alt", uid, sh.id.0)); - let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); - let mut alt = widgets::text_draft(stored, &sh.descr); - let r = ui.add(egui::TextEdit::multiline(&mut alt).hint_text("Alt text").desired_rows(3).desired_width(f32::INFINITY)); - if r.lost_focus() && alt != sh.descr { - run(app, "shape.altText", json!({"text": alt})); - } - ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); - } - "text" => { - ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0))); - color_button(app, ui, "Color", None, |c| match c { - Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), - None => ("format.color".into(), json!({"color": "tx1"})), - }); - text_transparency_slider(app, ui, &sh); - ui.add_space(6.0); - ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); - ui.horizontal(|ui| { - ui.label("Vertical alignment"); - for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).clicked() { - run(app, "format.anchor", json!({"anchor": a})); - } - } - }); - ui.horizontal_wrapped(|ui| { - for (l, m) in [("Do not Autofit", "none"), ("Shrink text on overflow", "shrink"), ("Resize shape to fit text", "resize")] { - if ui.small_button(l).clicked() { - run(app, "format.autofit", json!({"mode": m})); - } - } - }); - let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true); - let mut w = wrap; - if ui.checkbox(&mut w, "Wrap text in shape").changed() { - run(app, "format.wrap", json!({"on": w})); - } - ui.label("Margins (pt)"); - let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); - for (l, k, v) in [ - ("Left", "left", b.inset_l.unwrap_or(7.2)), - ("Right", "right", b.inset_r.unwrap_or(7.2)), - ("Top", "top", b.inset_t.unwrap_or(3.6)), - ("Bottom", "bottom", b.inset_b.unwrap_or(3.6)), - ] { - ui.horizontal(|ui| { - ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l)); - if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), l, v, 1.0, " pt", 60.0) { - run(app, "format.margins", json!({k: nv.max(0.0)})); - } - }); - } - ui.horizontal(|ui| { - ui.label("Columns"); - for n in 1..=3 { - if ui.small_button(n.to_string()).clicked() { - run(app, "format.columns", json!({"count": n, "spacing": 18})); - } - } - if ui.small_button("Columns…").clicked() { - run(app, "format.columns", json!({})); - } - }); - } - "picture" => { - let adj = if let ShapeKind::Picture { fill } = &sh.kind { fill.adjust.clone() } else { Default::default() }; - let mut b = adj.brightness as f32; - if ui.add(egui::Slider::new(&mut b, -1.0..=1.0).text("Brightness")).drag_stopped() { - run(app, "picture.adjust", json!({"brightness": b})); - } - let mut c = adj.contrast as f32; - if ui.add(egui::Slider::new(&mut c, -1.0..=1.0).text("Contrast")).drag_stopped() { - run(app, "picture.adjust", json!({"contrast": c})); - } - let mut s = adj.saturation.unwrap_or(1.0) as f32; - if ui.add(egui::Slider::new(&mut s, 0.0..=4.0).text("Saturation")).drag_stopped() { - run(app, "picture.adjust", json!({"saturation": s})); - } - let alpha = if let ShapeKind::Picture { fill } = &sh.kind { fill.alpha.unwrap_or(1.0) } else { 1.0 }; - let mut tr = (1.0 - alpha) as f32; - if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text("Transparency")).drag_stopped() { - run(app, "picture.adjust", json!({"transparency": tr})); - } - if ui.button("Reset Picture").clicked() { - run(app, "picture.reset", json!({})); - } - } - _ => { - // Fill & Line. - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); - let kind = match &sh.fill { - None => "auto", - Some(Fill::None) => "none", - Some(Fill::Solid { .. }) => "solid", - Some(Fill::Gradient(_)) => "gradient", - Some(Fill::Picture(_)) => "picture", - Some(Fill::Pattern(_)) => "pattern", - _ => "auto", - }; - for (l, k) in [ - ("No fill", "none"), - ("Solid fill", "solid"), - ("Gradient fill", "gradient"), - ("Picture or texture fill", "picture"), - ("Pattern fill", "pattern"), - ("Automatic", "auto"), - ] { - if ui.radio(kind == k, l).clicked() { - match k { - "none" => run(app, "shape.fill", json!({"none": true})), - "solid" => run(app, "shape.fill", json!({"color": "accent1"})), - "gradient" => run( - app, - "shape.fill", - json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), - ), - "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), - "picture" => pick_fill_picture(app), - _ => run(app, "shape.fill", json!({"reset": true})), - } - } - } - // The Color button sets a solid fill, so it shows only for a solid or automatic fill. A - // gradient takes its colors from its stops, and a pattern takes them from its Foreground - // and Background menus below. - if matches!(kind, "solid" | "auto") { - color_button(app, ui, "Color", Some("No Fill"), |c| match c { - Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})), - None => ("shape.fill".into(), json!({"none": true})), - }); - } - // `shape.fill {transparency}` changes a solid fill (an automatic one through its - // effective color) and a picture fill; a gradient has a transparency per stop. - let transparency = matches!(sh.fill, None | Some(Fill::Solid { .. } | Fill::Picture(_))); - ui.horizontal(|ui| { - if !transparency { - return; - } - let label = ui.label("Transparency"); - // Seed from this shape. A shared temp defaulted to 0, so a shape that was - // already 40% transparent showed as opaque, and leaving the slider wrote 0. - let seed = match &sh.fill { - Some(Fill::Picture(pf)) => ((1.0 - pf.alpha.unwrap_or(1.0)) * 100.0) as f32, - fill => solid_fill_transparency_percent(fill.as_ref()), - }; - let id = ui.scope_id().with(("ftr", sh.id.0)); - let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed)); - let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id); - if r.drag_stopped() || r.lost_focus() { - if let Some(t) = fill_transparency_to_apply(v, seed) { - let _ = app.run("shape.fill", json!({"transparency": t})); - } - ui.data_mut(|d| d.remove::(id)); - } else if r.dragged() || r.changed() { - ui.data_mut(|d| d.insert_temp(id, v)); - } - }); - if let Some(Fill::Picture(pf)) = &sh.fill { - picture_fill_options(app, ui, sh.id.0, pf); - } - if let Some(Fill::Gradient(g)) = &sh.fill { - let sc = scheme(app); - if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { - run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); - } - let mut rot = g.rotate_with_shape; - if ui.checkbox(&mut rot, "Rotate with shape").changed() { - let ng = deckcraft_model::style::Gradient { rotate_with_shape: rot, ..g.clone() }; - run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); - } - } - if let Some(Fill::Pattern(pt)) = &sh.fill - && let Some(np) = crate::fillui::pattern_editor(ui, pt, &scheme(app)) - { - run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)})); - } - ui.add_space(8.0); - ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); - let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); - if ui.radio(none, "No line").clicked() { - run(app, "shape.line", json!({"none": true})); - } - if ui.radio(!none && sh.line.is_some(), "Solid line").clicked() { - run(app, "shape.line", json!({"color": "tx1", "width": 1})); - } - if ui.radio(sh.line.is_none(), "Automatic").clicked() { - run(app, "shape.line", json!({"reset": true})); - } - color_button(app, ui, "Color", Some("No Outline"), |c| match c { - Some(c) => ("shape.line".into(), json!({"color": cref_param(&c)})), - None => ("shape.line".into(), json!({"none": true})), - }); - ui.horizontal(|ui| { - ui.label("Width"); - let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75); - if let Some(v) = widgets::spinner(ui, "lw", "Width", w, 0.25, " pt", 60.0) { - run(app, "shape.line", json!({"width": v.max(0.0)})); - } - }); - ui.horizontal_wrapped(|ui| { - ui.label("Dash"); - for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] { - if ui.small_button(l).clicked() { - run(app, "shape.line", json!({"dash": d})); - } - } - }); - line_details(app, ui, &sh); - } - } -} - -/// A labelled drop-down on the Format Shape pane: picks one of `items` (label, value) and runs -/// `shape.line` with `{key: value}`. -fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) { - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l); - let combo = egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { - for (l, v) in items { - if ui.selectable_label(*v == current, *l).clicked() { - run(app, "shape.line", json!({key: v})); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); -} - -/// The rest of PowerPoint's Line page: compound type, cap and join types, and the begin and end -/// arrows with their sizes. -fn line_details(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { - use deckcraft_model::style::{Compound, LineCap, LineJoin}; - let l = sh.line.clone().unwrap_or_default(); - let compound = match l.compound.unwrap_or_default() { - Compound::Single => "single", - Compound::Double => "double", - Compound::ThickThin => "thickThin", - Compound::ThinThick => "thinThick", - Compound::Triple => "triple", - }; - line_choice( - app, - ui, - "Compound type", - "compound", - compound, - &[("Single", "single"), ("Double", "double"), ("Thick Thin", "thickThin"), ("Thin Thick", "thinThick"), ("Triple", "triple")], - ); - let cap = match l.cap.unwrap_or_default() { - LineCap::Flat => "flat", - LineCap::Round => "round", - LineCap::Square => "square", - }; - line_choice(app, ui, "Cap type", "cap", cap, &[("Flat", "flat"), ("Round", "round"), ("Square", "square")]); - let join = match l.join.unwrap_or_default() { - LineJoin::Round => "round", - LineJoin::Bevel => "bevel", - LineJoin::Miter => "miter", - }; - line_choice(app, ui, "Join type", "join", join, &[("Round", "round"), ("Bevel", "bevel"), ("Miter", "miter")]); - let arrows = [("None", "none"), ("Arrow", "triangle"), ("Open Arrow", "arrow"), ("Stealth", "stealth"), ("Diamond", "diamond"), ("Oval", "oval")]; - let sizes = [("Small", "sm"), ("Medium", "med"), ("Large", "lg")]; - for (label, key, size_label, size_key, end) in - [("Begin arrow", "head", "Begin size", "headSize", &l.head), ("End arrow", "tail", "End size", "tailSize", &l.tail)] - { - line_choice(app, ui, label, key, end.as_ref().map_or("none", |e| e.kind.as_str()), &arrows); - if let Some(e) = end { - line_choice(app, ui, size_label, size_key, &e.w, &sizes); - } - } -} - -/// A slider for one property of a shape effect: shows `value` (in display units), and when the -/// drag ends runs `shape.effects` with `{key: {prop: shown × scale}}`. -#[allow(clippy::too_many_arguments)] -fn fx_slider( - app: &mut SlideApp, - ui: &mut Ui, - id: (&str, u32, &str), - label: &str, - value: f64, - range: std::ops::RangeInclusive, - suffix: &str, - key: &str, - prop: &str, - scale: f64, -) { - ui.horizontal(|ui| { - let label = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - // Hold the value while dragging; otherwise follow the shape. - let sid = ui.scope_id().with(id); - let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value); - let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)).labelled_by(label.id); - if r.dragged() || r.has_focus() { - ui.data_mut(|d| d.insert_temp(sid, v)); - } else { - ui.data_mut(|d| d.remove::(sid)); - } - if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { - ui.data_mut(|d| d.remove::(sid)); - run(app, "shape.effects", json!({key: {prop: v * scale}})); - } - }); -} - -/// Asks for a picture file and makes it the selected shapes' fill. -fn pick_fill_picture(app: &mut SlideApp) { - if let Some(pick) = app.services.pick_open.as_mut() - && let Some(path) = pick("picture") - && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) - { - run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); - } -} - -/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` -/// when it changes. -fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let mut v = value; - let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); - if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { - run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); - } - }); -} - -/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. -fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { - use deckcraft_model::style::PictureMode; - ui.horizontal(|ui| { - ui.label("Picture source"); - if ui.button("Insert…").clicked() { - pick_fill_picture(app); - } - }); - let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); - if ui.checkbox(&mut tile, "Tile picture as texture").changed() { - run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); - } - match &pf.mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); - pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); - pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); - pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); - let aligns = [ - ("tl", "Top left"), - ("t", "Top"), - ("tr", "Top right"), - ("l", "Left"), - ("ctr", "Center"), - ("r", "Right"), - ("bl", "Bottom left"), - ("b", "Bottom"), - ("br", "Bottom right"), - ]; - let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; - for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); - let combo = egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { - for (k, l) in opts { - if ui.selectable_label(*k == cur.as_str(), *l).clicked() { - run(app, "shape.fill", json!({"pictureProps": {key: k}})); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - } - } - PictureMode::Stretch { fill_rect } => { - for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let mut v = fill_rect[i] * 100.0; - let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)).labelled_by(field.id); - if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { - let mut rect = *fill_rect; - rect[i] = v / 100.0; - run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); - } - }); - } - } - } - let mut rot = !pf.upright; - if ui.checkbox(&mut rot, "Rotate with shape").changed() { - run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); - } -} - -/// "perspectiveHeroicLeftFacing" → "Perspective heroic left facing". -fn preset_label(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if i == 0 { - out.extend(c.to_uppercase()); - } else if c.is_uppercase() { - out.push(' '); - out.extend(c.to_lowercase()); - } else { - out.push(c); - } - } - out -} - -/// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`. -fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let combo = egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { - for o in options { - if ui.selectable_label(*o == cur, preset_label(o)).clicked() { - run(app, "shape.effects", json!({"threeD": {key: o}})); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); -} - -/// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes. -fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) { - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let mut v = value; - let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); - if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { - run(app, "shape.effects", json!({"threeD": {key: v}})); - } - }); -} - -/// Format Shape ▸ Effects ▸ 3-D Format and 3-D Rotation. -fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) { - use deckcraft_engine::cmd::three_d::{BEVELS, CAMERAS, LIGHTS, MATERIALS, Shape3d}; - let s3 = raw.map(Shape3d::parse).unwrap_or_default(); - let with_none: Vec<&str> = std::iter::once("none").chain(BEVELS.iter().copied()).collect(); - ui.add_space(6.0); - ui.label(egui::RichText::new("3-D Format").font(theme::bold(13.0))); - for (label, key, b) in [("Top bevel", "bevelTop", &s3.top), ("Bottom bevel", "bevelBottom", &s3.bottom)] { - three_d_combo(app, ui, id, label, key, b.as_ref().map_or("none", |b| b.preset.as_str()), &with_none); - if let Some(b) = b { - three_d_drag(app, ui, " Width", b.w, " pt", &format!("{key}W")); - three_d_drag(app, ui, " Height", b.h, " pt", &format!("{key}H")); - } - } - three_d_drag(app, ui, "Depth", s3.depth, " pt", "depth"); - three_d_drag(app, ui, "Contour", s3.contour, " pt", "contour"); - three_d_combo(app, ui, id, "Material", "material", &s3.material, MATERIALS); - three_d_combo(app, ui, id, "Lighting", "lighting", &s3.light, LIGHTS); - ui.add_space(6.0); - ui.label(egui::RichText::new("3-D Rotation").font(theme::bold(13.0))); - three_d_combo(app, ui, id, "Presets", "camera", &s3.camera, CAMERAS); - let (rx, ry, rz) = s3.rot.unwrap_or((0.0, 0.0, 0.0)); - three_d_drag(app, ui, "X Rotation", rx, "°", "rotX"); - three_d_drag(app, ui, "Y Rotation", ry, "°", "rotY"); - three_d_drag(app, ui, "Z Rotation", rz, "°", "rotZ"); - three_d_drag(app, ui, "Distance from ground", s3.z, " pt", "z"); - if ui.add_enabled(raw.is_some(), egui::Button::new("Reset 3-D")).clicked() { - run(app, "shape.effects", json!({"threeD": {"reset": true}})); - } -} - -fn background(app: &mut SlideApp, ui: &mut Ui) { - let sc = scheme(app); - let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); - let fill = match &bg { - Some(deckcraft_model::Background::Fill { fill }) => Some(fill.clone()), - _ => None, - }; - let kind = match &fill { - Some(Fill::Solid { .. }) => "solid", - Some(Fill::Gradient(_)) => "gradient", - Some(Fill::Picture(_)) => "picture", - Some(Fill::Pattern(_)) => "pattern", - _ => "auto", - }; - // What "Apply to All" re-applies: the current slide's background as command params. - let mut current: Option = None; - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); - for (l, k) in [ - ("Solid fill", "solid"), - ("Gradient fill", "gradient"), - ("Picture or texture fill", "picture"), - ("Pattern fill", "pattern"), - ("Follow the layout", "auto"), - ] { - if ui.radio(kind == k, l).clicked() && kind != k { - match k { - "solid" => run(app, "design.background", json!({"color": "bg1"})), - "gradient" => { - let mut g = crate::fillui::default_gradient(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg2), 90.0); - g.rotate_with_shape = false; - run(app, "design.background", json!({"gradient": crate::fillui::gradient_json(&g)})); - } - "picture" => { - if let Some(pick) = app.services.pick_open.as_mut() - && let Some(path) = pick("picture") - && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) - { - let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); - } - } - "pattern" => run(app, "design.background", json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": "bg1"}})), - _ => run(app, "design.background", json!({"reset": true})), - } - } - } - ui.add_space(4.0); - match &fill { - Some(Fill::Solid { color }) => { - current = Some(json!({"color": cref_param(color)})); - ui.horizontal(|ui| { - ui.label("Color"); - let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); - widgets::color_popup(&r).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - run(app, "design.background", json!({"color": cref_param(&c)})); - } - }); - }); - let mut tr = ((1.0 - color.alpha()) * 100.0) as f32; - if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { - run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); - } - } - Some(Fill::Gradient(g)) => { - current = Some(json!({"gradient": crate::fillui::gradient_json(g)})); - if let Some(ng) = crate::fillui::gradient_editor(ui, "bg-grad", g, &sc) { - run(app, "design.background", json!({"gradient": crate::fillui::gradient_json(&ng)})); - } - } - Some(Fill::Pattern(pt)) => { - current = Some(json!({"pattern": crate::fillui::pattern_json(pt)})); - if let Some(np) = crate::fillui::pattern_editor(ui, pt, &sc) { - run(app, "design.background", json!({"pattern": crate::fillui::pattern_json(&np)})); - } - } - Some(Fill::Picture(pf)) => { - if ui.button("Insert picture from file…").clicked() - && let Some(pick) = app.services.pick_open.as_mut() - && let Some(path) = pick("picture") - && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) - { - let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); - } - picture_background_options(app, ui, pf); - } - _ => {} - } - ui.add_space(4.0); - let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false); - let mut h = hide; - if ui.checkbox(&mut h, "Hide background graphics").changed() { - run(app, "design.hideBackgroundGraphics", json!({"hide": h})); - } - ui.add_space(8.0); - let picture = matches!(fill, Some(Fill::Picture(_))); - ui.horizontal(|ui| { - if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { - if let Some(Value::Object(mut o)) = current.clone() { - o.insert("all".into(), json!(true)); - run(app, "design.background", Value::Object(o)); - } else if picture { - run(app, "design.background", json!({"copy": true})); - } - } - if ui.button("Reset Background").clicked() { - run(app, "design.background", json!({"reset": true})); - } - }); -} - -/// Render picture-fill background controls and route transparency, tiling, and tile geometry -/// changes through the engine. -fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { - use deckcraft_model::style::PictureMode; - let alpha = pf.alpha.unwrap_or(1.0); - let mut tr = ((1.0 - alpha) * 100.0) as f32; - if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { - run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); - } - let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); - if ui.checkbox(&mut tile, "Tile picture as texture").changed() { - run(app, "design.background", json!({"pictureProps": {"tile": tile}})); - } - let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; - for (label, id, value, suffix, scale) in [ - ("Offset X", "bg-ox", *tx, " pt", 1.0), - ("Offset Y", "bg-oy", *ty, " pt", 1.0), - ("Scale X", "bg-sx", *sx * 100.0, "%", 0.01), - ("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01), - ] { - ui.horizontal(|ui| { - ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); - if let Some(v) = widgets::spinner(ui, id, label, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { - let key = match id { - "bg-ox" => "offsetX", - "bg-oy" => "offsetY", - "bg-sx" => "scaleX", - _ => "scaleY", - }; - run(app, "design.background", json!({"pictureProps": {key: v * scale}})); - } - }); - } - let align_name = match align.as_str() { - "t" => "Top", - "tr" => "Top Right", - "l" => "Left", - "ctr" => "Center", - "r" => "Right", - "bl" => "Bottom Left", - "b" => "Bottom", - "br" => "Bottom Right", - _ => "Top Left", - }; - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); - let combo = egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { - for (k, l) in [ - ("tl", "Top Left"), - ("t", "Top"), - ("tr", "Top Right"), - ("l", "Left"), - ("ctr", "Center"), - ("r", "Right"), - ("bl", "Bottom Left"), - ("b", "Bottom"), - ("br", "Bottom Right"), - ] { - if ui.selectable_label(align == k, l).clicked() { - run(app, "design.background", json!({"pictureProps": {"align": k}})); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - let mirror_name = match flip.as_str() { - "x" => "Horizontal", - "y" => "Vertical", - "xy" => "Both", - _ => "None", - }; - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); - let combo = egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { - for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { - if ui.selectable_label(flip == k, l).clicked() { - run(app, "design.background", json!({"pictureProps": {"mirror": k}})); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); -} - -/// Render the animation pane and route its controls through the engine's animation commands. -fn animation(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let Some(slide) = st.current_slide() else { return }; - let anims = slide.animations.clone(); - let size = (st.doc.slide_size.width, st.doc.slide_size.height); - let tl = deckcraft_anim::Timeline::new(slide, size.0, size.1); - let effects = tl.effects(); - let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); - let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); - // Play From starts at the effect last clicked here, or else the first effect of the selected - // shape; with nothing selected it is Play All. Reordering or removing forgets the click: the - // index would point at another effect. - let pick_id = egui::Id::unique(("anim_pane_pick", st.uid, st.selection.slide)); - let pick = ui.data(|m| m.get_temp::(pick_id)); - // A triggered effect is not on the click sequence, so it can't be played from. - let eligible = |i: usize| anims.get(i).is_some_and(|a| selected.contains(&a.shape.0)) && tl.start_of(i).is_some(); - let from = pick.filter(|&i| eligible(i)).or_else(|| (0..anims.len()).find(|&i| eligible(i))); - ui.horizontal(|ui| { - let playing = app.anim_preview_active(); - let label = match (playing, from) { - (true, _) => "■ Stop", - (false, Some(_)) => "▶ Play From", - (false, None) => "▶ Play All", - }; - if ui.button(label).clicked() { - let p = match (playing, from) { - (false, Some(i)) => json!({"on": true, "from": i}), - _ => json!({"on": !playing}), - }; - run(app, "animation.preview", p); - } - }); - ui.separator(); - if anims.is_empty() { - ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim)); - return; - } - let bars = animtimeline::bars(anims.len(), &effects); - let seconds = animtimeline::ruler_seconds(&bars); - let track_w = (ui.available_width() * 0.45).max(60.0); - let pps = animtimeline::pixels_per_second(track_w, seconds); - let mut click = 0; - for (i, a) in anims.iter().enumerate() { - if a.start == AnimStart::OnClick { - click += 1; - } - let name = names.iter().find(|(id, _)| *id == a.shape.0).map(|(_, n)| n.clone()).unwrap_or_else(|| format!("Shape {}", a.shape)); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); - let sel = selected.contains(&a.shape.0); - if sel || resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); - } - if from == Some(i) { - ui.painter().rect_stroke(r, CornerRadius::same(4), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - } - let p = ui.painter(); - p.text( - pos2(r.min.x + 6.0, r.center().y), - Align2::LEFT_CENTER, - if a.start == AnimStart::OnClick { click.to_string() } else { String::new() }, - theme::font(12.0), - t.text_dim, - ); - let icon = match a.class { - AnimClass::Entrance => Icon::AnimEntrance, - AnimClass::Emphasis => Icon::AnimEmphasis, - AnimClass::Exit => Icon::AnimExit, - _ => Icon::AnimPath, - }; - icons::paint(p, Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(18.0, 18.0)), icon, t.text, false); - p.text(pos2(r.min.x + 44.0, r.center().y), Align2::LEFT_CENTER, &name, theme::font(12.5), t.text); - // Advanced timeline bar: drag the body to change the delay, the right edge for the duration. - if let Some(Some(b)) = bars.get(i) { - let col = match a.class { - AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F), - AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C), - AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B), - _ => Color32::from_rgb(0x2E, 0x6F, 0xD8), - }; - let x0 = r.max.x - track_w - 6.0; - let x_of = |sec: f64| x0 + (sec as f32 * pps).min(track_w); - let mut bar = Rect::from_min_max( - pos2(x_of(b.start), r.center().y - 5.0), - pos2(x_of(b.start + b.span).max(x_of(b.start) + 4.0), r.center().y + 5.0), - ); - let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0)); - let body_resp = ui.interact(bar, ui.scope_id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b)); - body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Timing bar {}", i + 1))); - let edge_resp = ui.interact(edge, ui.scope_id().with(("anim_edge", i)), Sense::drag()); - edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Duration handle {}", i + 1))); - if edge_resp.hovered() || edge_resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } else if body_resp.hovered() || body_resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - // The drag offset is remembered: the pointer's press origin is gone on release. - let dx_id = ui.scope_id().with(("anim_drag_dx", i)); - let live = ui.input(|inp| match (inp.pointer.press_origin(), inp.pointer.interact_pos()) { - (Some(o), Some(p)) => Some(p.x - o.x), - _ => None, - }); - let dx = match live { - Some(d) if body_resp.dragged() || edge_resp.dragged() => { - ui.data_mut(|m| m.insert_temp(dx_id, d)); - d - } - _ => ui.data_mut(|m| m.get_temp::(dx_id)).unwrap_or(0.0), - }; - if body_resp.drag_stopped() || edge_resp.drag_stopped() { - ui.data_mut(|m| m.remove::(dx_id)); - } - let iter_secs = f64::from(a.duration_ms) / 1000.0; - let factor = if iter_secs > 0.0 && b.span > 0.0 { b.span / iter_secs } else { 1.0 }; - if edge_resp.dragged() || edge_resp.drag_stopped() { - let d = animtimeline::dragged_duration(a.duration_ms, dx, pps, factor); - bar.max.x = x_of(b.start + f64::from(d) / 1000.0 * factor).max(bar.min.x + 4.0); - if edge_resp.drag_stopped() && d != a.duration_ms { - run(app, "animation.timing", json!({"index": i, "duration": d})); - } - } else if body_resp.dragged() || body_resp.drag_stopped() { - let d = animtimeline::dragged_delay(a.delay_ms, dx, pps); - let shift = (f64::from(d) - f64::from(a.delay_ms)) / 1000.0; - let w = bar.width(); - bar.min.x = x_of((b.start + shift).max(0.0)); - bar.max.x = bar.min.x + w; - if body_resp.drag_stopped() && d != a.delay_ms { - run(app, "animation.timing", json!({"index": i, "delay": d})); - } - } - let p = ui.painter(); - p.rect_filled(bar, CornerRadius::same(2), col); - if a.repeat == u32::MAX { - p.text(pos2(bar.max.x + 2.0, bar.center().y), Align2::LEFT_CENTER, "⟳", theme::font(10.0), t.text_dim); - } - } - if resp.clicked() { - ui.data_mut(|m| m.insert_temp(pick_id, i)); - run(app, "edit.select", json!({"ids": [a.shape.0]})); - } - resp.context_menu(|ui| { - for (l, v) in [("Start On Click", "onClick"), ("Start With Previous", "withPrevious"), ("Start After Previous", "afterPrevious")] { - if ui.button(l).clicked() { - run(app, "animation.timing", json!({"index": i, "start": v})); - ui.close(); - } - } - ui.separator(); - if ui.button("Move Up").clicked() { - ui.data_mut(|m| m.remove::(pick_id)); - run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); - ui.close(); - } - if ui.button("Move Down").clicked() { - ui.data_mut(|m| m.remove::(pick_id)); - run(app, "animation.move", json!({"index": i, "to": i + 1})); - ui.close(); - } - if ui.button("Remove").clicked() { - ui.data_mut(|m| m.remove::(pick_id)); - run(app, "animation.remove", json!({"index": i})); - ui.close(); - } - }); - } - // Seconds ruler under the bars. - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); - let x0 = r.max.x - track_w - 6.0; - let p = ui.painter(); - p.line_segment([pos2(x0, r.min.y + 2.0), pos2(x0 + track_w, r.min.y + 2.0)], Stroke::new(1.0, t.text_faint)); - let every = (seconds / 6).max(1); - for s in (0..=seconds).step_by(every as usize) { - let x = x0 + s as f32 * pps; - if x > x0 + track_w + 0.5 { - break; - } - p.line_segment([pos2(x, r.min.y), pos2(x, r.min.y + 5.0)], Stroke::new(1.0, t.text_faint)); - p.text(pos2(x, r.min.y + 6.0), Align2::CENTER_TOP, format!("{s}s"), theme::font(10.0), t.text_dim); - } - let _ = paint_anim_tile; -} - -/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it, -/// Reject to drop it. -fn revisions(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Ok(v) = app.session.execute("review.revisions", &json!({})) else { - ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); - return; - }; - let changes = v["changes"].as_array().cloned().unwrap_or_default(); - ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); - if v["byPosition"].as_bool() == Some(true) { - ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); - } - ui.add_space(4.0); - if changes.is_empty() { - ui.label("No differences left."); - if ui.button("End Review").clicked() { - run(app, "review.endCompare", json!({})); - app.ui.pane = None; - } - return; - } - ui.horizontal(|ui| { - if ui.button(format!("Accept All ({})", changes.len())).clicked() { - run(app, "review.accept", json!({"all": true})); - } - if ui.button(format!("Reject All ({})", changes.len())).clicked() { - run(app, "review.reject", json!({"all": true})); - } - }); - ui.separator(); - for (i, c) in changes.iter().enumerate() { - let slide = c["slide"].as_u64().unwrap_or(0); - let (head, col) = match c["kind"].as_str().unwrap_or("") { - "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), - "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), - _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), - }; - egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(2), col); - if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { - let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); - run(app, "slide.go", json!({"index": (slide as usize).min(max)})); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Reject").clicked() { - run(app, "review.reject", json!({"index": i})); - } - if ui.small_button("Accept").clicked() { - run(app, "review.accept", json!({"index": i})); - } - }); - }); - for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { - ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); - } - }); - ui.add_space(4.0); - } -} - -fn comments(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - if ui.button("+ New Comment").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("comment")); - } - ui.separator(); - let threads: Vec<(usize, Vec)> = app - .session - .active() - .map(|st| st.doc.slides.iter().enumerate().filter(|(_, sl)| !sl.comments.is_empty()).map(|(i, sl)| (i, sl.comments.clone())).collect()) - .unwrap_or_default(); - if threads.is_empty() { - ui.label(egui::RichText::new("No comments.").color(t.text_dim)); - return; - } - let focus = app.ui.comment_focus; - for (si, list) in threads { - ui.label(egui::RichText::new(format!("Slide {}", si + 1)).font(theme::bold(12.0)).color(t.text_dim)); - ui.add_space(2.0); - for (i, c) in list.iter().enumerate() { - comment_card(app, ui, si, i, c, focus == Some((si, i))); - ui.add_space(6.0); - } - } -} - -fn focus_comment(app: &mut SlideApp, slide: usize, index: usize) { - let _ = app.run("slide.go", json!({"index": slide})); - app.ui.comment_focus = Some((slide, index)); -} - -/// Render one comment card, preserving its slide/index identity and focus state through actions. -fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: &deckcraft_model::Comment, focused: bool) { - let t = Tokens::get(ui.ctx()); - let stroke = if focused { Stroke::new(2.0, t.accent) } else { Stroke::NONE }; - let shown = egui::Frame::NONE - .fill(if focused { t.selected } else { t.chrome }) - .stroke(stroke) - .corner_radius(CornerRadius::same(8)) - .inner_margin(egui::Margin::same(8)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - let head = ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); - ui.painter().circle_filled(r.center(), 12.0, t.accent); - ui.painter().text(r.center(), Align2::CENTER_CENTER, &c.initials, theme::bold(11.0), Color32::WHITE); - ui.vertical(|ui| { - let name = ui.add(egui::Label::new(egui::RichText::new(&c.author).font(theme::bold(12.5))).sense(Sense::click())); - if name.clicked() { - focus_comment(app, slide, index); - } - let when = c.date.replace('T', " ").trim_end_matches('Z').to_string(); - let when = if c.resolved { format!("{when} · Resolved") } else { when }; - ui.label(egui::RichText::new(when).size(10.5).color(t.text_faint)); - }); - }); - if head.response.clicked() { - focus_comment(app, slide, index); - } - let body = ui.add(egui::Label::new(&c.text).sense(Sense::click())); - if body.clicked() { - focus_comment(app, slide, index); - } - for r in &c.replies { - ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); - } - ui.horizontal(|ui| { - let id = ui.scope_id().with(("reply", slide, index)); - let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { - focus_comment(app, slide, index); - run(app, "comment.reply", json!({"index": index, "text": txt})); - txt.clear(); - } - ui.data_mut(|d| d.insert_temp(id, txt)); - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - focus_comment(app, slide, index); - run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); - } - if ui.small_button("🗑").clicked() { - let _ = app.run("slide.go", json!({"index": slide})); - let _ = app.run("comment.delete", json!({"index": index})); - match app.ui.comment_focus { - Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, - Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), - _ => {} - } - } - }); - }); - if focused { - let key = egui::Id::unique("comment-focus-scrolled"); - let seen = ui.data(|d| d.get_temp::<(usize, usize)>(key)); - if seen != Some((slide, index)) { - ui.scroll_to_rect(shown.response.rect, Some(egui::Align::Center)); - ui.data_mut(|d| d.insert_temp(key, (slide, index))); - } - } -} - -/// Design Ideas: offline suggestions — the current slide in each theme and layout variations. -/// Rendered idea thumbnails, kept until the deck, slide or pane width changes (rendering eight -/// slides every frame made the pane cost ~25 ms a frame). -#[derive(Clone, Default)] -struct IdeaThumbs { - key: (u64, u64, usize, u32), - tex: Vec<(String, egui::TextureHandle, u32, u32)>, -} - -/// Render per-slide Design Ideas and whole-presentation theme thumbnails; a click runs -/// `design.applyIdea` or `design.theme`. -fn designer(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); - ui.add_space(6.0); - let Some(st) = app.session.active() else { return }; - let doc = st.doc.clone(); - let idx = st.selection.slide; - let sel = st.selection.clone(); - let key_doc = (st.uid, st.revision); - let w = ui.available_width() - 8.0; - let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp).round().max(16.0) as u32; - let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx); - let themes = deckcraft_model::theme::builtin_themes(); - let cache_id = egui::Id::unique("design-idea-thumbs"); - let key = (key_doc.0, key_doc.1, idx, px); - let mut cache: IdeaThumbs = ui.ctx().data_mut(|d| d.get_temp(cache_id)).unwrap_or_default(); - if cache.key != key || cache.tex.is_empty() { - cache = IdeaThumbs { key, tex: vec![] }; - let mut render = |name: String, d: &deckcraft_model::Presentation| { - let (px, h) = crate::textures::fit_size( - (px, (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), - crate::textures::max_side(ui.ctx()), - ); - let img = deckcraft_render::render_slide( - d, - idx, - &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, - ); - let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false)); - cache.tex.push((name, tex, px, h)); - }; - for (id, _) in &ideas { - let mut d = (*doc).clone(); - if deckcraft_engine::cmd::ideas::apply_idea(&mut d, &sel, idx, id).is_ok() { - render(format!("layout:{id}"), &d); - } - } - for th in &themes { - let mut d = (*doc).clone(); - if let Some(m) = d.masters.first_mut() { - std::sync::Arc::make_mut(m).theme = th.clone(); - } - render(format!("theme:{}", th.name), &d); - } - ui.ctx().data_mut(|d| d.insert_temp(cache_id, cache.clone())); - } - let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool { - let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false }; - let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click()); - // On the physical pixel grid, one texel per device pixel (#184). - let at = crate::textures::device_pixel_rect(r, ppp); - let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp)); - ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - ui.painter().rect_stroke( - at, - CornerRadius::same(2), - Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), - egui::StrokeKind::Outside, - ); - let clicked = resp.on_hover_text(tip).clicked(); - ui.add_space(8.0); - clicked - }; - if ideas.is_empty() { - ui.label(egui::RichText::new("Add a title with a picture or some text to get layout ideas.").color(t.text_dim)); - } - for (id, label) in &ideas { - if thumb(ui, &format!("layout:{id}"), label) { - run(app, "design.applyIdea", json!({"id": id})); - } - } - ui.add_space(4.0); - ui.label(egui::RichText::new("Themes (whole presentation)").color(t.text_dim)); - ui.add_space(4.0); - for th in &themes { - if thumb(ui, &format!("theme:{}", th.name), &th.name) { - run(app, "design.theme", json!({"name": th.name})); - } - } -} - -/// The transparency (0–1) of a shape's first text run as stored on it. -pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { - let run = sh.text.as_ref().and_then(|t| t.paragraphs.iter().flat_map(|p| p.runs.iter()).next()); - match run.and_then(|r| r.props.fill.as_ref()) { - Some(Fill::Solid { color }) => 1.0 - color.alpha(), - Some(Fill::Gradient(g)) => g.stops.first().map_or(0.0, |s| 1.0 - s.color.alpha()), - _ => 0.0, - } -} - -/// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). -pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { - ui.horizontal(|ui| { - let label = ui.label("Transparency"); - let id = ui.scope_id().with(("text-transparency", sh.id)); - let stored = (text_transparency_of(sh) * 100.0) as f32; - let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); - let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id); - if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { - let _ = app.run("format.textTransparency", json!({"value": v as f64 / 100.0})); - ui.data_mut(|d| d.remove::(id)); - } else if r.dragged() { - ui.data_mut(|d| d.insert_temp(id, v)); - } - }); -} - +pub mod animtimeline; pub mod fillui; pub mod notes; pub mod selpane; #[cfg(test)] mod smartart_tests; pub mod thumbs; -pub mod animtimeline; #[cfg(test)] mod background_tests; @@ -1682,40 +302,5 @@ mod tests { } } -/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word -/// when editing text, otherwise looks that synonym up in turn. -fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let mut q = app.thesaurus_query.clone(); - let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); - if edit.changed() { - app.thesaurus_query = q.clone(); - } - ui.add_space(6.0); - let senses = deckcraft_engine::thesaurus::lookup(&q); - if q.trim().is_empty() { - ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); - return; - } - if senses.is_empty() { - ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); - return; - } - let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); - for sense in senses { - ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); - for syn in sense.synonyms { - let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); - if r.clicked() { - if editing { - run(app, "review.replaceWord", json!({"with": syn})); - } - app.thesaurus_query = syn; - } - } - ui.add_space(6.0); - } -} - #[cfg(test)] mod thesaurus_tests; diff --git a/crates/ui-egui/src/panels/animation.rs b/crates/ui-egui/src/panels/animation.rs new file mode 100644 index 000000000..95eea93f6 --- /dev/null +++ b/crates/ui-egui/src/panels/animation.rs @@ -0,0 +1,184 @@ +use super::*; + +/// Render the animation pane and route its controls through the engine's animation commands. +pub(super) fn animation(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(slide) = st.current_slide() else { return }; + let anims = slide.animations.clone(); + let size = (st.doc.slide_size.width, st.doc.slide_size.height); + let tl = deckcraft_anim::Timeline::new(slide, size.0, size.1); + let effects = tl.effects(); + let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + // Play From starts at the effect last clicked here, or else the first effect of the selected + // shape; with nothing selected it is Play All. Reordering or removing forgets the click: the + // index would point at another effect. + let pick_id = egui::Id::unique(("anim_pane_pick", st.uid, st.selection.slide)); + let pick = ui.data(|m| m.get_temp::(pick_id)); + // A triggered effect is not on the click sequence, so it can't be played from. + let eligible = |i: usize| anims.get(i).is_some_and(|a| selected.contains(&a.shape.0)) && tl.start_of(i).is_some(); + let from = pick.filter(|&i| eligible(i)).or_else(|| (0..anims.len()).find(|&i| eligible(i))); + ui.horizontal(|ui| { + let playing = app.anim_preview_active(); + let label = match (playing, from) { + (true, _) => "■ Stop", + (false, Some(_)) => "▶ Play From", + (false, None) => "▶ Play All", + }; + if ui.button(label).clicked() { + let p = match (playing, from) { + (false, Some(i)) => json!({"on": true, "from": i}), + _ => json!({"on": !playing}), + }; + run(app, "animation.preview", p); + } + }); + ui.separator(); + if anims.is_empty() { + ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim)); + return; + } + let bars = animtimeline::bars(anims.len(), &effects); + let seconds = animtimeline::ruler_seconds(&bars); + let track_w = (ui.available_width() * 0.45).max(60.0); + let pps = animtimeline::pixels_per_second(track_w, seconds); + let mut click = 0; + for (i, a) in anims.iter().enumerate() { + if a.start == AnimStart::OnClick { + click += 1; + } + let name = names.iter().find(|(id, _)| *id == a.shape.0).map(|(_, n)| n.clone()).unwrap_or_else(|| format!("Shape {}", a.shape)); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + let sel = selected.contains(&a.shape.0); + if sel || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); + } + if from == Some(i) { + ui.painter().rect_stroke(r, CornerRadius::same(4), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + let p = ui.painter(); + p.text( + pos2(r.min.x + 6.0, r.center().y), + Align2::LEFT_CENTER, + if a.start == AnimStart::OnClick { click.to_string() } else { String::new() }, + theme::font(12.0), + t.text_dim, + ); + let icon = match a.class { + AnimClass::Entrance => Icon::AnimEntrance, + AnimClass::Emphasis => Icon::AnimEmphasis, + AnimClass::Exit => Icon::AnimExit, + _ => Icon::AnimPath, + }; + icons::paint(p, Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(18.0, 18.0)), icon, t.text, false); + p.text(pos2(r.min.x + 44.0, r.center().y), Align2::LEFT_CENTER, &name, theme::font(12.5), t.text); + // Advanced timeline bar: drag the body to change the delay, the right edge for the duration. + if let Some(Some(b)) = bars.get(i) { + let col = match a.class { + AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F), + AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C), + AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B), + _ => Color32::from_rgb(0x2E, 0x6F, 0xD8), + }; + let x0 = r.max.x - track_w - 6.0; + let x_of = |sec: f64| x0 + (sec as f32 * pps).min(track_w); + let mut bar = Rect::from_min_max( + pos2(x_of(b.start), r.center().y - 5.0), + pos2(x_of(b.start + b.span).max(x_of(b.start) + 4.0), r.center().y + 5.0), + ); + let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0)); + let body_resp = ui.interact(bar, ui.scope_id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b)); + body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Timing bar {}", i + 1))); + let edge_resp = ui.interact(edge, ui.scope_id().with(("anim_edge", i)), Sense::drag()); + edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Duration handle {}", i + 1))); + if edge_resp.hovered() || edge_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } else if body_resp.hovered() || body_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + // The drag offset is remembered: the pointer's press origin is gone on release. + let dx_id = ui.scope_id().with(("anim_drag_dx", i)); + let live = ui.input(|inp| match (inp.pointer.press_origin(), inp.pointer.interact_pos()) { + (Some(o), Some(p)) => Some(p.x - o.x), + _ => None, + }); + let dx = match live { + Some(d) if body_resp.dragged() || edge_resp.dragged() => { + ui.data_mut(|m| m.insert_temp(dx_id, d)); + d + } + _ => ui.data_mut(|m| m.get_temp::(dx_id)).unwrap_or(0.0), + }; + if body_resp.drag_stopped() || edge_resp.drag_stopped() { + ui.data_mut(|m| m.remove::(dx_id)); + } + let iter_secs = f64::from(a.duration_ms) / 1000.0; + let factor = if iter_secs > 0.0 && b.span > 0.0 { b.span / iter_secs } else { 1.0 }; + if edge_resp.dragged() || edge_resp.drag_stopped() { + let d = animtimeline::dragged_duration(a.duration_ms, dx, pps, factor); + bar.max.x = x_of(b.start + f64::from(d) / 1000.0 * factor).max(bar.min.x + 4.0); + if edge_resp.drag_stopped() && d != a.duration_ms { + run(app, "animation.timing", json!({"index": i, "duration": d})); + } + } else if body_resp.dragged() || body_resp.drag_stopped() { + let d = animtimeline::dragged_delay(a.delay_ms, dx, pps); + let shift = (f64::from(d) - f64::from(a.delay_ms)) / 1000.0; + let w = bar.width(); + bar.min.x = x_of((b.start + shift).max(0.0)); + bar.max.x = bar.min.x + w; + if body_resp.drag_stopped() && d != a.delay_ms { + run(app, "animation.timing", json!({"index": i, "delay": d})); + } + } + let p = ui.painter(); + p.rect_filled(bar, CornerRadius::same(2), col); + if a.repeat == u32::MAX { + p.text(pos2(bar.max.x + 2.0, bar.center().y), Align2::LEFT_CENTER, "⟳", theme::font(10.0), t.text_dim); + } + } + if resp.clicked() { + ui.data_mut(|m| m.insert_temp(pick_id, i)); + run(app, "edit.select", json!({"ids": [a.shape.0]})); + } + resp.context_menu(|ui| { + for (l, v) in [("Start On Click", "onClick"), ("Start With Previous", "withPrevious"), ("Start After Previous", "afterPrevious")] { + if ui.button(l).clicked() { + run(app, "animation.timing", json!({"index": i, "start": v})); + ui.close(); + } + } + ui.separator(); + if ui.button("Move Up").clicked() { + ui.data_mut(|m| m.remove::(pick_id)); + run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); + ui.close(); + } + if ui.button("Move Down").clicked() { + ui.data_mut(|m| m.remove::(pick_id)); + run(app, "animation.move", json!({"index": i, "to": i + 1})); + ui.close(); + } + if ui.button("Remove").clicked() { + ui.data_mut(|m| m.remove::(pick_id)); + run(app, "animation.remove", json!({"index": i})); + ui.close(); + } + }); + } + // Seconds ruler under the bars. + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + let x0 = r.max.x - track_w - 6.0; + let p = ui.painter(); + p.line_segment([pos2(x0, r.min.y + 2.0), pos2(x0 + track_w, r.min.y + 2.0)], Stroke::new(1.0, t.text_faint)); + let every = (seconds / 6).max(1); + for s in (0..=seconds).step_by(every as usize) { + let x = x0 + s as f32 * pps; + if x > x0 + track_w + 0.5 { + break; + } + p.line_segment([pos2(x, r.min.y), pos2(x, r.min.y + 5.0)], Stroke::new(1.0, t.text_faint)); + p.text(pos2(x, r.min.y + 6.0), Align2::CENTER_TOP, format!("{s}s"), theme::font(10.0), t.text_dim); + } + let _ = paint_anim_tile; +} diff --git a/crates/ui-egui/src/panels/background.rs b/crates/ui-egui/src/panels/background.rs new file mode 100644 index 000000000..0373616f7 --- /dev/null +++ b/crates/ui-egui/src/panels/background.rs @@ -0,0 +1,195 @@ +use super::*; + +pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); + let fill = match &bg { + Some(deckcraft_model::Background::Fill { fill }) => Some(fill.clone()), + _ => None, + }; + let kind = match &fill { + Some(Fill::Solid { .. }) => "solid", + Some(Fill::Gradient(_)) => "gradient", + Some(Fill::Picture(_)) => "picture", + Some(Fill::Pattern(_)) => "pattern", + _ => "auto", + }; + // What "Apply to All" re-applies: the current slide's background as command params. + let mut current: Option = None; + ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + for (l, k) in [ + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Follow the layout", "auto"), + ] { + if ui.radio(kind == k, l).clicked() && kind != k { + match k { + "solid" => run(app, "design.background", json!({"color": "bg1"})), + "gradient" => { + let mut g = crate::fillui::default_gradient(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg2), 90.0); + g.rotate_with_shape = false; + run(app, "design.background", json!({"gradient": crate::fillui::gradient_json(&g)})); + } + "picture" => { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } + } + "pattern" => run(app, "design.background", json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": "bg1"}})), + _ => run(app, "design.background", json!({"reset": true})), + } + } + } + ui.add_space(4.0); + match &fill { + Some(Fill::Solid { color }) => { + current = Some(json!({"color": cref_param(color)})); + ui.horizontal(|ui| { + ui.label("Color"); + let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); + widgets::color_popup(&r).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "design.background", json!({"color": cref_param(&c)})); + } + }); + }); + let mut tr = ((1.0 - color.alpha()) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } + } + Some(Fill::Gradient(g)) => { + current = Some(json!({"gradient": crate::fillui::gradient_json(g)})); + if let Some(ng) = crate::fillui::gradient_editor(ui, "bg-grad", g, &sc) { + run(app, "design.background", json!({"gradient": crate::fillui::gradient_json(&ng)})); + } + } + Some(Fill::Pattern(pt)) => { + current = Some(json!({"pattern": crate::fillui::pattern_json(pt)})); + if let Some(np) = crate::fillui::pattern_editor(ui, pt, &sc) { + run(app, "design.background", json!({"pattern": crate::fillui::pattern_json(&np)})); + } + } + Some(Fill::Picture(pf)) => { + if ui.button("Insert picture from file…").clicked() + && let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } + picture_background_options(app, ui, pf); + } + _ => {} + } + ui.add_space(4.0); + let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false); + let mut h = hide; + if ui.checkbox(&mut h, "Hide background graphics").changed() { + run(app, "design.hideBackgroundGraphics", json!({"hide": h})); + } + ui.add_space(8.0); + let picture = matches!(fill, Some(Fill::Picture(_))); + ui.horizontal(|ui| { + if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if let Some(Value::Object(mut o)) = current.clone() { + o.insert("all".into(), json!(true)); + run(app, "design.background", Value::Object(o)); + } else if picture { + run(app, "design.background", json!({"copy": true})); + } + } + if ui.button("Reset Background").clicked() { + run(app, "design.background", json!({"reset": true})); + } + }); +} + +/// Render picture-fill background controls and route transparency, tiling, and tile geometry +/// changes through the engine. +fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + let alpha = pf.alpha.unwrap_or(1.0); + let mut tr = ((1.0 - alpha) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "design.background", json!({"pictureProps": {"tile": tile}})); + } + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; + for (label, id, value, suffix, scale) in [ + ("Offset X", "bg-ox", *tx, " pt", 1.0), + ("Offset Y", "bg-oy", *ty, " pt", 1.0), + ("Scale X", "bg-sx", *sx * 100.0, "%", 0.01), + ("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01), + ] { + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); + if let Some(v) = widgets::spinner(ui, id, label, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { + let key = match id { + "bg-ox" => "offsetX", + "bg-oy" => "offsetY", + "bg-sx" => "scaleX", + _ => "scaleY", + }; + run(app, "design.background", json!({"pictureProps": {key: v * scale}})); + } + }); + } + let align_name = match align.as_str() { + "t" => "Top", + "tr" => "Top Right", + "l" => "Left", + "ctr" => "Center", + "r" => "Right", + "bl" => "Bottom Left", + "b" => "Bottom", + "br" => "Bottom Right", + _ => "Top Left", + }; + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); + let combo = egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [ + ("tl", "Top Left"), + ("t", "Top"), + ("tr", "Top Right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom Left"), + ("b", "Bottom"), + ("br", "Bottom Right"), + ] { + if ui.selectable_label(align == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"align": k}})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + let mirror_name = match flip.as_str() { + "x" => "Horizontal", + "y" => "Vertical", + "xy" => "Both", + _ => "None", + }; + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); + let combo = egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { + if ui.selectable_label(flip == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"mirror": k}})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); +} diff --git a/crates/ui-egui/src/panels/comments.rs b/crates/ui-egui/src/panels/comments.rs new file mode 100644 index 000000000..0b384579a --- /dev/null +++ b/crates/ui-egui/src/panels/comments.rs @@ -0,0 +1,166 @@ +use super::*; + +/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it, +/// Reject to drop it. +pub(super) fn revisions(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("review.revisions", &json!({})) else { + ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + return; + }; + let changes = v["changes"].as_array().cloned().unwrap_or_default(); + ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + if v["byPosition"].as_bool() == Some(true) { + ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + } + ui.add_space(4.0); + if changes.is_empty() { + ui.label("No differences left."); + if ui.button("End Review").clicked() { + run(app, "review.endCompare", json!({})); + app.ui.pane = None; + } + return; + } + ui.horizontal(|ui| { + if ui.button(format!("Accept All ({})", changes.len())).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if ui.button(format!("Reject All ({})", changes.len())).clicked() { + run(app, "review.reject", json!({"all": true})); + } + }); + ui.separator(); + for (i, c) in changes.iter().enumerate() { + let slide = c["slide"].as_u64().unwrap_or(0); + let (head, col) = match c["kind"].as_str().unwrap_or("") { + "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + }; + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(2), col); + if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { + let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); + run(app, "slide.go", json!({"index": (slide as usize).min(max)})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Reject").clicked() { + run(app, "review.reject", json!({"index": i})); + } + if ui.small_button("Accept").clicked() { + run(app, "review.accept", json!({"index": i})); + } + }); + }); + for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { + ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); + } + }); + ui.add_space(4.0); + } +} + +pub(super) fn comments(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if ui.button("+ New Comment").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("comment")); + } + ui.separator(); + let threads: Vec<(usize, Vec)> = app + .session + .active() + .map(|st| st.doc.slides.iter().enumerate().filter(|(_, sl)| !sl.comments.is_empty()).map(|(i, sl)| (i, sl.comments.clone())).collect()) + .unwrap_or_default(); + if threads.is_empty() { + ui.label(egui::RichText::new("No comments.").color(t.text_dim)); + return; + } + let focus = app.ui.comment_focus; + for (si, list) in threads { + ui.label(egui::RichText::new(format!("Slide {}", si + 1)).font(theme::bold(12.0)).color(t.text_dim)); + ui.add_space(2.0); + for (i, c) in list.iter().enumerate() { + comment_card(app, ui, si, i, c, focus == Some((si, i))); + ui.add_space(6.0); + } + } +} + +fn focus_comment(app: &mut SlideApp, slide: usize, index: usize) { + let _ = app.run("slide.go", json!({"index": slide})); + app.ui.comment_focus = Some((slide, index)); +} + +/// Render one comment card, preserving its slide/index identity and focus state through actions. +fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: &deckcraft_model::Comment, focused: bool) { + let t = Tokens::get(ui.ctx()); + let stroke = if focused { Stroke::new(2.0, t.accent) } else { Stroke::NONE }; + let shown = egui::Frame::NONE + .fill(if focused { t.selected } else { t.chrome }) + .stroke(stroke) + .corner_radius(CornerRadius::same(8)) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + let head = ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 12.0, t.accent); + ui.painter().text(r.center(), Align2::CENTER_CENTER, &c.initials, theme::bold(11.0), Color32::WHITE); + ui.vertical(|ui| { + let name = ui.add(egui::Label::new(egui::RichText::new(&c.author).font(theme::bold(12.5))).sense(Sense::click())); + if name.clicked() { + focus_comment(app, slide, index); + } + let when = c.date.replace('T', " ").trim_end_matches('Z').to_string(); + let when = if c.resolved { format!("{when} · Resolved") } else { when }; + ui.label(egui::RichText::new(when).size(10.5).color(t.text_faint)); + }); + }); + if head.response.clicked() { + focus_comment(app, slide, index); + } + let body = ui.add(egui::Label::new(&c.text).sense(Sense::click())); + if body.clicked() { + focus_comment(app, slide, index); + } + for r in &c.replies { + ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); + } + ui.horizontal(|ui| { + let id = ui.scope_id().with(("reply", slide, index)); + let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { + focus_comment(app, slide, index); + run(app, "comment.reply", json!({"index": index, "text": txt})); + txt.clear(); + } + ui.data_mut(|d| d.insert_temp(id, txt)); + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + focus_comment(app, slide, index); + run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); + } + if ui.small_button("🗑").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("comment.delete", json!({"index": index})); + match app.ui.comment_focus { + Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, + Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), + _ => {} + } + } + }); + }); + if focused { + let key = egui::Id::unique("comment-focus-scrolled"); + let seen = ui.data(|d| d.get_temp::<(usize, usize)>(key)); + if seen != Some((slide, index)) { + ui.scroll_to_rect(shown.response.rect, Some(egui::Align::Center)); + ui.data_mut(|d| d.insert_temp(key, (slide, index))); + } + } +} diff --git a/crates/ui-egui/src/panels/designer.rs b/crates/ui-egui/src/panels/designer.rs new file mode 100644 index 000000000..422e00859 --- /dev/null +++ b/crates/ui-egui/src/panels/designer.rs @@ -0,0 +1,94 @@ +use super::*; + +/// Design Ideas: offline suggestions — the current slide in each theme and layout variations. +/// Rendered idea thumbnails, kept until the deck, slide or pane width changes (rendering eight +/// slides every frame made the pane cost ~25 ms a frame). +#[derive(Clone, Default)] +struct IdeaThumbs { + key: (u64, u64, usize, u32), + tex: Vec<(String, egui::TextureHandle, u32, u32)>, +} + +/// Render per-slide Design Ideas and whole-presentation theme thumbnails; a click runs +/// `design.applyIdea` or `design.theme`. +pub(super) fn designer(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); + ui.add_space(6.0); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let idx = st.selection.slide; + let sel = st.selection.clone(); + let key_doc = (st.uid, st.revision); + let w = ui.available_width() - 8.0; + let ppp = ui.ctx().pixels_per_point(); + let px = (w * ppp).round().max(16.0) as u32; + let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx); + let themes = deckcraft_model::theme::builtin_themes(); + let cache_id = egui::Id::unique("design-idea-thumbs"); + let key = (key_doc.0, key_doc.1, idx, px); + let mut cache: IdeaThumbs = ui.ctx().data_mut(|d| d.get_temp(cache_id)).unwrap_or_default(); + if cache.key != key || cache.tex.is_empty() { + cache = IdeaThumbs { key, tex: vec![] }; + let mut render = |name: String, d: &deckcraft_model::Presentation| { + let (px, h) = crate::textures::fit_size( + (px, (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); + let img = deckcraft_render::render_slide( + d, + idx, + &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, + ); + let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false)); + cache.tex.push((name, tex, px, h)); + }; + for (id, _) in &ideas { + let mut d = (*doc).clone(); + if deckcraft_engine::cmd::ideas::apply_idea(&mut d, &sel, idx, id).is_ok() { + render(format!("layout:{id}"), &d); + } + } + for th in &themes { + let mut d = (*doc).clone(); + if let Some(m) = d.masters.first_mut() { + std::sync::Arc::make_mut(m).theme = th.clone(); + } + render(format!("theme:{}", th.name), &d); + } + ui.ctx().data_mut(|d| d.insert_temp(cache_id, cache.clone())); + } + let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool { + let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false }; + let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click()); + // On the physical pixel grid, one texel per device pixel (#184). + let at = crate::textures::device_pixel_rect(r, ppp); + let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp)); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + ui.painter().rect_stroke( + at, + CornerRadius::same(2), + Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + let clicked = resp.on_hover_text(tip).clicked(); + ui.add_space(8.0); + clicked + }; + if ideas.is_empty() { + ui.label(egui::RichText::new("Add a title with a picture or some text to get layout ideas.").color(t.text_dim)); + } + for (id, label) in &ideas { + if thumb(ui, &format!("layout:{id}"), label) { + run(app, "design.applyIdea", json!({"id": id})); + } + } + ui.add_space(4.0); + ui.label(egui::RichText::new("Themes (whole presentation)").color(t.text_dim)); + ui.add_space(4.0); + for th in &themes { + if thumb(ui, &format!("theme:{}", th.name), &th.name) { + run(app, "design.theme", json!({"name": th.name})); + } + } +} diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs new file mode 100644 index 000000000..77b26bf45 --- /dev/null +++ b/crates/ui-egui/src/panels/format_shape.rs @@ -0,0 +1,676 @@ +use super::*; + +/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s +/// `transparency` parameter writes as an alpha transform. Anything else (no fill, a +/// gradient, a picture, a pattern) is 0. +pub(super) fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 { + let Some(Fill::Solid { color }) = fill else { + return 0.0; + }; + ((1.0 - color.alpha()) * 100.0) as f32 +} + +/// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus +/// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill. +pub(super) fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option { + if !shown_percent.is_finite() { + return None; + } + let shown = shown_percent.clamp(0.0, 100.0); + if (shown - seed_percent).abs() <= 0.01 { + return None; + } + Some(f64::from(shown) / 100.0) +} + +/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. +pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { + ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); + return; + }; + let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); + let uid = st.uid; + // Tabs as icons. + ui.horizontal(|ui| { + for (tab, icon, tip) in [ + ("fill", Icon::ShapeFill, "Fill & Line"), + ("effects", Icon::ShapeEffects, "Effects"), + ("size", Icon::SlideSize, "Size & Properties"), + ("text", Icon::TextBox, "Text Options"), + ] { + let on = app.ui.format_tab == tab; + if widgets::icon_toggle(ui, icon, tip, true, on).clicked() { + app.ui.format_tab = tab.into(); + } + } + if matches!(sh.kind, ShapeKind::Picture { .. }) + && widgets::icon_toggle(ui, Icon::Picture, "Picture", true, app.ui.format_tab == "picture").clicked() + { + app.ui.format_tab = "picture".into(); + } + }); + ui.separator(); + match app.ui.format_tab.as_str() { + "effects" => { + ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in [ + ("None", "none"), + ("Outer", "outer"), + ("Bottom", "bottom"), + ("Center", "center"), + ("Inner", "inner"), + ("Perspective", "perspective"), + ] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); + } + } + }); + let fx = sh.effects.clone().unwrap_or_default(); + let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); + if let Some(sw) = shadow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("shadow", sh.id.0, "t"), + "Transparency", + (1.0 - sw.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "shadowProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "°", "shadowProps", "dir", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + if let Some(r) = &fx.reflection { + fx_slider( + app, + ui, + ("refl", sh.id.0, "t"), + "Transparency", + (1.0 - r.start_alpha) * 100.0, + 0.0..=100.0, + "%", + "reflectionProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); + fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); + fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); + fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); + if let Some(g) = &fx.glow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("glow", sh.id.0, "t"), + "Transparency", + (1.0 - g.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "glowProps", + "transparency", + 0.01, + ); + } + ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); + let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { + run(app, "shape.effects", json!({"softEdges": s})); + } + let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); + three_d_options(app, ui, sh.id.0, raw3d.as_deref()); + if ui.button("Reset Effects").clicked() { + run(app, "shape.effects", json!({"reset": true})); + } + } + "size" => { + let lock_id = egui::Id::unique("fmt-lock-aspect"); + let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); + let locked = lock; + let len = app.session.prefs.units; + let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| { + let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) }; + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, suffix, 70.0) { + let mut p = json!({key: nv * unit}); + if cmd == "shape.resize" && locked { + p["lockAspect"] = json!(true); + } + run(app, cmd, p); + } + }); + }; + ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); + fields(ui, "Height", "fh", x.h, "h", "shape.resize", false); + fields(ui, "Width", "fw", x.w, "w", "shape.resize", false); + fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true); + if ui.checkbox(&mut lock, "Lock aspect ratio").changed() { + ui.data_mut(|d| d.insert_temp(lock_id, lock)); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); + fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false); + fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false); + ui.add_space(6.0); + ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); + let id = ui.scope_id().with(("alt", uid, sh.id.0)); + let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); + let mut alt = widgets::text_draft(stored, &sh.descr); + let r = ui.add(egui::TextEdit::multiline(&mut alt).hint_text("Alt text").desired_rows(3).desired_width(f32::INFINITY)); + if r.lost_focus() && alt != sh.descr { + run(app, "shape.altText", json!({"text": alt})); + } + ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); + } + "text" => { + ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0))); + color_button(app, ui, "Color", None, |c| match c { + Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), + None => ("format.color".into(), json!({"color": "tx1"})), + }); + text_transparency_slider(app, ui, &sh); + ui.add_space(6.0); + ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); + ui.horizontal(|ui| { + ui.label("Vertical alignment"); + for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { + if ui.small_button(l).clicked() { + run(app, "format.anchor", json!({"anchor": a})); + } + } + }); + ui.horizontal_wrapped(|ui| { + for (l, m) in [("Do not Autofit", "none"), ("Shrink text on overflow", "shrink"), ("Resize shape to fit text", "resize")] { + if ui.small_button(l).clicked() { + run(app, "format.autofit", json!({"mode": m})); + } + } + }); + let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true); + let mut w = wrap; + if ui.checkbox(&mut w, "Wrap text in shape").changed() { + run(app, "format.wrap", json!({"on": w})); + } + ui.label("Margins (pt)"); + let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); + for (l, k, v) in [ + ("Left", "left", b.inset_l.unwrap_or(7.2)), + ("Right", "right", b.inset_r.unwrap_or(7.2)), + ("Top", "top", b.inset_t.unwrap_or(3.6)), + ("Bottom", "bottom", b.inset_b.unwrap_or(3.6)), + ] { + ui.horizontal(|ui| { + ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l)); + if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), l, v, 1.0, " pt", 60.0) { + run(app, "format.margins", json!({k: nv.max(0.0)})); + } + }); + } + ui.horizontal(|ui| { + ui.label("Columns"); + for n in 1..=3 { + if ui.small_button(n.to_string()).clicked() { + run(app, "format.columns", json!({"count": n, "spacing": 18})); + } + } + if ui.small_button("Columns…").clicked() { + run(app, "format.columns", json!({})); + } + }); + } + "picture" => { + let adj = if let ShapeKind::Picture { fill } = &sh.kind { fill.adjust.clone() } else { Default::default() }; + let mut b = adj.brightness as f32; + if ui.add(egui::Slider::new(&mut b, -1.0..=1.0).text("Brightness")).drag_stopped() { + run(app, "picture.adjust", json!({"brightness": b})); + } + let mut c = adj.contrast as f32; + if ui.add(egui::Slider::new(&mut c, -1.0..=1.0).text("Contrast")).drag_stopped() { + run(app, "picture.adjust", json!({"contrast": c})); + } + let mut s = adj.saturation.unwrap_or(1.0) as f32; + if ui.add(egui::Slider::new(&mut s, 0.0..=4.0).text("Saturation")).drag_stopped() { + run(app, "picture.adjust", json!({"saturation": s})); + } + let alpha = if let ShapeKind::Picture { fill } = &sh.kind { fill.alpha.unwrap_or(1.0) } else { 1.0 }; + let mut tr = (1.0 - alpha) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text("Transparency")).drag_stopped() { + run(app, "picture.adjust", json!({"transparency": tr})); + } + if ui.button("Reset Picture").clicked() { + run(app, "picture.reset", json!({})); + } + } + _ => { + // Fill & Line. + ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + let kind = match &sh.fill { + None => "auto", + Some(Fill::None) => "none", + Some(Fill::Solid { .. }) => "solid", + Some(Fill::Gradient(_)) => "gradient", + Some(Fill::Picture(_)) => "picture", + Some(Fill::Pattern(_)) => "pattern", + _ => "auto", + }; + for (l, k) in [ + ("No fill", "none"), + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Automatic", "auto"), + ] { + if ui.radio(kind == k, l).clicked() { + match k { + "none" => run(app, "shape.fill", json!({"none": true})), + "solid" => run(app, "shape.fill", json!({"color": "accent1"})), + "gradient" => run( + app, + "shape.fill", + json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), + ), + "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + "picture" => pick_fill_picture(app), + _ => run(app, "shape.fill", json!({"reset": true})), + } + } + } + // The Color button sets a solid fill, so it shows only for a solid or automatic fill. A + // gradient takes its colors from its stops, and a pattern takes them from its Foreground + // and Background menus below. + if matches!(kind, "solid" | "auto") { + color_button(app, ui, "Color", Some("No Fill"), |c| match c { + Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})), + None => ("shape.fill".into(), json!({"none": true})), + }); + } + // `shape.fill {transparency}` changes a solid fill (an automatic one through its + // effective color) and a picture fill; a gradient has a transparency per stop. + let transparency = matches!(sh.fill, None | Some(Fill::Solid { .. } | Fill::Picture(_))); + ui.horizontal(|ui| { + if !transparency { + return; + } + let label = ui.label("Transparency"); + // Seed from this shape. A shared temp defaulted to 0, so a shape that was + // already 40% transparent showed as opaque, and leaving the slider wrote 0. + let seed = match &sh.fill { + Some(Fill::Picture(pf)) => ((1.0 - pf.alpha.unwrap_or(1.0)) * 100.0) as f32, + fill => solid_fill_transparency_percent(fill.as_ref()), + }; + let id = ui.scope_id().with(("ftr", sh.id.0)); + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed)); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id); + if r.drag_stopped() || r.lost_focus() { + if let Some(t) = fill_transparency_to_apply(v, seed) { + let _ = app.run("shape.fill", json!({"transparency": t})); + } + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() || r.changed() { + ui.data_mut(|d| d.insert_temp(id, v)); + } + }); + if let Some(Fill::Picture(pf)) = &sh.fill { + picture_fill_options(app, ui, sh.id.0, pf); + } + if let Some(Fill::Gradient(g)) = &sh.fill { + let sc = scheme(app); + if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { + run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); + } + let mut rot = g.rotate_with_shape; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + let ng = deckcraft_model::style::Gradient { rotate_with_shape: rot, ..g.clone() }; + run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); + } + } + if let Some(Fill::Pattern(pt)) = &sh.fill + && let Some(np) = crate::fillui::pattern_editor(ui, pt, &scheme(app)) + { + run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)})); + } + ui.add_space(8.0); + ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); + let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); + if ui.radio(none, "No line").clicked() { + run(app, "shape.line", json!({"none": true})); + } + if ui.radio(!none && sh.line.is_some(), "Solid line").clicked() { + run(app, "shape.line", json!({"color": "tx1", "width": 1})); + } + if ui.radio(sh.line.is_none(), "Automatic").clicked() { + run(app, "shape.line", json!({"reset": true})); + } + color_button(app, ui, "Color", Some("No Outline"), |c| match c { + Some(c) => ("shape.line".into(), json!({"color": cref_param(&c)})), + None => ("shape.line".into(), json!({"none": true})), + }); + ui.horizontal(|ui| { + ui.label("Width"); + let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75); + if let Some(v) = widgets::spinner(ui, "lw", "Width", w, 0.25, " pt", 60.0) { + run(app, "shape.line", json!({"width": v.max(0.0)})); + } + }); + ui.horizontal_wrapped(|ui| { + ui.label("Dash"); + for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] { + if ui.small_button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + } + } + }); + line_details(app, ui, &sh); + } + } +} + +/// A labelled drop-down on the Format Shape pane: picks one of `items` (label, value) and runs +/// `shape.line` with `{key: value}`. +fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) { + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l); + let combo = egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { + for (l, v) in items { + if ui.selectable_label(*v == current, *l).clicked() { + run(app, "shape.line", json!({key: v})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); +} + +/// The rest of PowerPoint's Line page: compound type, cap and join types, and the begin and end +/// arrows with their sizes. +fn line_details(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + use deckcraft_model::style::{Compound, LineCap, LineJoin}; + let l = sh.line.clone().unwrap_or_default(); + let compound = match l.compound.unwrap_or_default() { + Compound::Single => "single", + Compound::Double => "double", + Compound::ThickThin => "thickThin", + Compound::ThinThick => "thinThick", + Compound::Triple => "triple", + }; + line_choice( + app, + ui, + "Compound type", + "compound", + compound, + &[("Single", "single"), ("Double", "double"), ("Thick Thin", "thickThin"), ("Thin Thick", "thinThick"), ("Triple", "triple")], + ); + let cap = match l.cap.unwrap_or_default() { + LineCap::Flat => "flat", + LineCap::Round => "round", + LineCap::Square => "square", + }; + line_choice(app, ui, "Cap type", "cap", cap, &[("Flat", "flat"), ("Round", "round"), ("Square", "square")]); + let join = match l.join.unwrap_or_default() { + LineJoin::Round => "round", + LineJoin::Bevel => "bevel", + LineJoin::Miter => "miter", + }; + line_choice(app, ui, "Join type", "join", join, &[("Round", "round"), ("Bevel", "bevel"), ("Miter", "miter")]); + let arrows = [("None", "none"), ("Arrow", "triangle"), ("Open Arrow", "arrow"), ("Stealth", "stealth"), ("Diamond", "diamond"), ("Oval", "oval")]; + let sizes = [("Small", "sm"), ("Medium", "med"), ("Large", "lg")]; + for (label, key, size_label, size_key, end) in + [("Begin arrow", "head", "Begin size", "headSize", &l.head), ("End arrow", "tail", "End size", "tailSize", &l.tail)] + { + line_choice(app, ui, label, key, end.as_ref().map_or("none", |e| e.kind.as_str()), &arrows); + if let Some(e) = end { + line_choice(app, ui, size_label, size_key, &e.w, &sizes); + } + } +} + +/// A slider for one property of a shape effect: shows `value` (in display units), and when the +/// drag ends runs `shape.effects` with `{key: {prop: shown × scale}}`. +#[allow(clippy::too_many_arguments)] +fn fx_slider( + app: &mut SlideApp, + ui: &mut Ui, + id: (&str, u32, &str), + label: &str, + value: f64, + range: std::ops::RangeInclusive, + suffix: &str, + key: &str, + prop: &str, + scale: f64, +) { + ui.horizontal(|ui| { + let label = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + // Hold the value while dragging; otherwise follow the shape. + let sid = ui.scope_id().with(id); + let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value); + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)).labelled_by(label.id); + if r.dragged() || r.has_focus() { + ui.data_mut(|d| d.insert_temp(sid, v)); + } else { + ui.data_mut(|d| d.remove::(sid)); + } + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + ui.data_mut(|d| d.remove::(sid)); + run(app, "shape.effects", json!({key: {prop: v * scale}})); + } + }); +} + +/// Asks for a picture file and makes it the selected shapes' fill. +fn pick_fill_picture(app: &mut SlideApp) { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } +} + +/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` +/// when it changes. +fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); + } + }); +} + +/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. +fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + ui.horizontal(|ui| { + ui.label("Picture source"); + if ui.button("Insert…").clicked() { + pick_fill_picture(app); + } + }); + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); + } + match &pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); + pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); + pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); + pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); + let aligns = [ + ("tl", "Top left"), + ("t", "Top"), + ("tr", "Top right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom left"), + ("b", "Bottom"), + ("br", "Bottom right"), + ]; + let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; + for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); + let combo = egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in opts { + if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + run(app, "shape.fill", json!({"pictureProps": {key: k}})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + } + } + PictureMode::Stretch { fill_rect } => { + for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = fill_rect[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)).labelled_by(field.id); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let mut rect = *fill_rect; + rect[i] = v / 100.0; + run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); + } + }); + } + } + } + let mut rot = !pf.upright; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); + } +} + +/// "perspectiveHeroicLeftFacing" → "Perspective heroic left facing". +fn preset_label(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if i == 0 { + out.extend(c.to_uppercase()); + } else if c.is_uppercase() { + out.push(' '); + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`. +fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let combo = egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { + for o in options { + if ui.selectable_label(*o == cur, preset_label(o)).clicked() { + run(app, "shape.effects", json!({"threeD": {key: o}})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); +} + +/// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes. +fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) { + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.effects", json!({"threeD": {key: v}})); + } + }); +} + +/// Format Shape ▸ Effects ▸ 3-D Format and 3-D Rotation. +fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) { + use deckcraft_engine::cmd::three_d::{BEVELS, CAMERAS, LIGHTS, MATERIALS, Shape3d}; + let s3 = raw.map(Shape3d::parse).unwrap_or_default(); + let with_none: Vec<&str> = std::iter::once("none").chain(BEVELS.iter().copied()).collect(); + ui.add_space(6.0); + ui.label(egui::RichText::new("3-D Format").font(theme::bold(13.0))); + for (label, key, b) in [("Top bevel", "bevelTop", &s3.top), ("Bottom bevel", "bevelBottom", &s3.bottom)] { + three_d_combo(app, ui, id, label, key, b.as_ref().map_or("none", |b| b.preset.as_str()), &with_none); + if let Some(b) = b { + three_d_drag(app, ui, " Width", b.w, " pt", &format!("{key}W")); + three_d_drag(app, ui, " Height", b.h, " pt", &format!("{key}H")); + } + } + three_d_drag(app, ui, "Depth", s3.depth, " pt", "depth"); + three_d_drag(app, ui, "Contour", s3.contour, " pt", "contour"); + three_d_combo(app, ui, id, "Material", "material", &s3.material, MATERIALS); + three_d_combo(app, ui, id, "Lighting", "lighting", &s3.light, LIGHTS); + ui.add_space(6.0); + ui.label(egui::RichText::new("3-D Rotation").font(theme::bold(13.0))); + three_d_combo(app, ui, id, "Presets", "camera", &s3.camera, CAMERAS); + let (rx, ry, rz) = s3.rot.unwrap_or((0.0, 0.0, 0.0)); + three_d_drag(app, ui, "X Rotation", rx, "°", "rotX"); + three_d_drag(app, ui, "Y Rotation", ry, "°", "rotY"); + three_d_drag(app, ui, "Z Rotation", rz, "°", "rotZ"); + three_d_drag(app, ui, "Distance from ground", s3.z, " pt", "z"); + if ui.add_enabled(raw.is_some(), egui::Button::new("Reset 3-D")).clicked() { + run(app, "shape.effects", json!({"threeD": {"reset": true}})); + } +} + +/// The transparency (0–1) of a shape's first text run as stored on it. +pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { + let run = sh.text.as_ref().and_then(|t| t.paragraphs.iter().flat_map(|p| p.runs.iter()).next()); + match run.and_then(|r| r.props.fill.as_ref()) { + Some(Fill::Solid { color }) => 1.0 - color.alpha(), + Some(Fill::Gradient(g)) => g.stops.first().map_or(0.0, |s| 1.0 - s.color.alpha()), + _ => 0.0, + } +} + +/// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). +pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + ui.horizontal(|ui| { + let label = ui.label("Transparency"); + let id = ui.scope_id().with(("text-transparency", sh.id)); + let stored = (text_transparency_of(sh) * 100.0) as f32; + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let _ = app.run("format.textTransparency", json!({"value": v as f64 / 100.0})); + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() { + ui.data_mut(|d| d.insert_temp(id, v)); + } + }); +} diff --git a/crates/ui-egui/src/panels/smartart.rs b/crates/ui-egui/src/panels/smartart.rs new file mode 100644 index 000000000..d21393e99 --- /dev/null +++ b/crates/ui-egui/src/panels/smartart.rs @@ -0,0 +1,90 @@ +use super::*; + +/// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and +/// Shift+Tab demote and promote, Backspace in an empty line removes it. +pub(super) fn smartart(app: &mut SlideApp, ui: &mut Ui) { + let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { + ui.label("Select a SmartArt graphic to edit its text."); + return; + }; + let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); + let focused_any = ui.ctx().memory(|m| m.focused().is_some()) || app.ui.smartart_focus.is_some(); + if app.ui.smartart_drafts.0 != gid.0 || app.ui.smartart_drafts.1.len() != texts.len() || !focused_any { + app.ui.smartart_drafts = (gid.0, texts.clone()); + } + let mut drafts = std::mem::take(&mut app.ui.smartart_drafts.1); + let mut action: Option<(&str, Value)> = None; + let mut commit: Option = None; + let mut focus_row: Option = None; + ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(4.0); + for (i, node) in d.nodes.iter().enumerate() { + let Some(draft) = drafts.get_mut(i) else { break }; + let id = egui::Id::unique(("smartart_line", gid.0, i)); + ui.horizontal(|ui| { + ui.add_space(16.0 * node.level as f32); + ui.label("•"); + let te = egui::TextEdit::singleline(draft).id(id).hint_text("SmartArt text").desired_width(f32::INFINITY); + let r = ui.add(te); + if app.ui.smartart_focus == Some(i) { + r.request_focus(); + app.ui.smartart_focus = None; + } + if r.gained_focus() || r.has_focus() { + app.ui.smartart_row = i; + } + if r.has_focus() { + let back = ui.input(|inp| inp.key_pressed(egui::Key::Backspace)); + if back && draft.is_empty() && texts.get(i).is_some_and(String::is_empty) && d.nodes.len() > 1 { + action = Some(("smartArt.removeNode", json!({"index": i}))); + focus_row = Some(i.saturating_sub(1)); + } + } + if r.lost_focus() { + let (tab, shift) = ui.input(|inp| (inp.key_pressed(egui::Key::Tab), inp.modifiers.shift)); + if tab { + action = Some((if shift { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": i}))); + focus_row = Some(i); + } else if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + action = Some(("smartArt.addNode", json!({"index": i, "position": "after"}))); + focus_row = Some(i + 1); + } + commit = Some(i); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("+ Add").clicked() { + action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); + } + if ui.button("Remove").clicked() && !d.nodes.is_empty() { + action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); + } + }); + // Commit a changed line before any structural edit, so the edit sees the new text. + if let Some(i) = commit + && drafts.get(i) != texts.get(i) + { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": drafts.get(i).cloned().unwrap_or_default()})); + } + if action.is_some() { + for (i, t) in drafts.iter().enumerate() { + if Some(i) != commit && texts.get(i) != Some(t) { + run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": t})); + } + } + } + app.ui.smartart_drafts.1 = drafts; + if let Some((cmd, mut p)) = action { + if let Some(o) = p.as_object_mut() { + o.insert("id".into(), json!(gid.0)); + } + if let Ok(r) = app.run(cmd, p) { + let row = r.get("index").and_then(Value::as_u64).map(|v| v as usize).or(focus_row).unwrap_or(0); + app.ui.smartart_row = row; + app.ui.smartart_drafts.0 = 0; + app.ui.smartart_focus = Some(row); + } + } +} diff --git a/crates/ui-egui/src/panels/thesaurus.rs b/crates/ui-egui/src/panels/thesaurus.rs new file mode 100644 index 000000000..b79e8ddbc --- /dev/null +++ b/crates/ui-egui/src/panels/thesaurus.rs @@ -0,0 +1,36 @@ +use super::*; + +/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word +/// when editing text, otherwise looks that synonym up in turn. +pub(super) fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let mut q = app.thesaurus_query.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + if edit.changed() { + app.thesaurus_query = q.clone(); + } + ui.add_space(6.0); + let senses = deckcraft_engine::thesaurus::lookup(&q); + if q.trim().is_empty() { + ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + return; + } + if senses.is_empty() { + ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + return; + } + let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); + for sense in senses { + ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); + for syn in sense.synonyms { + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + if r.clicked() { + if editing { + run(app, "review.replaceWord", json!({"with": syn})); + } + app.thesaurus_query = syn; + } + } + ui.add_space(6.0); + } +} From 99688fe45a70781198bddbfc3541f2f456a67e89 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:44:22 +0000 Subject: [PATCH 45/90] refactor(ui-egui): split show.rs into show/{render,keys,ui,media,presenter,zoom}.rs --- crates/ui-egui/src/show.rs | 1865 +------------------------- crates/ui-egui/src/show/keys.rs | 148 ++ crates/ui-egui/src/show/media.rs | 254 ++++ crates/ui-egui/src/show/presenter.rs | 258 ++++ crates/ui-egui/src/show/render.rs | 210 +++ crates/ui-egui/src/show/ui.rs | 612 +++++++++ crates/ui-egui/src/show/zoom.rs | 403 ++++++ 7 files changed, 1905 insertions(+), 1845 deletions(-) create mode 100644 crates/ui-egui/src/show/keys.rs create mode 100644 crates/ui-egui/src/show/media.rs create mode 100644 crates/ui-egui/src/show/presenter.rs create mode 100644 crates/ui-egui/src/show/render.rs create mode 100644 crates/ui-egui/src/show/ui.rs create mode 100644 crates/ui-egui/src/show/zoom.rs diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 403b6b593..342dfd2cd 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -12,6 +12,26 @@ use serde_json::json; use crate::SlideApp; use crate::theme; +mod keys; +mod media; +mod presenter; +mod render; +mod ui; +mod zoom; + +#[cfg(test)] +use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars}; +use presenter::{NOTES_DEFAULT, PresenterTimer}; +#[cfg(test)] +use presenter::{NOTES_MAX, NOTES_MIN, NOTES_STEP, format_elapsed, presenter_notes, presenter_window, wall_clock}; +#[cfg(test)] +use render::{below_playing_video, next_rect, render_frame, start_prefetch}; +use render::{render_backdrop, render_threads}; +pub use ui::ui; +use zoom::Zoom; +#[cfg(test)] +use zoom::{GOTO_DIGITS, norm_on_slide, see_all_cells, see_all_hit, toggle_blank, unit_uv, wheel_zoom_lines, zoom_uv}; + pub struct Show { /// Monotonic identity of this show instance. Starting a show again (even at the same slide /// and step) is a new instance, so a queued click cannot cross an ABA transition. @@ -102,10 +122,6 @@ struct Prefetch { rx: std::sync::mpsc::Receiver, } -fn render_threads() -> u16 { - if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } -} - /// What a slide's backdrop was rendered for. #[derive(Clone, Copy, Debug, PartialEq)] struct BackKey { @@ -127,141 +143,6 @@ struct Backdrop { rx: Option>, } -fn render_backdrop(doc: &Presentation, key: BackKey, threads: u16) -> deckcraft_render::Image { - let opts = RenderOpts { scale: key.size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(key.size), threads, ..Default::default() }; - deckcraft_render::render_slide_part(doc, key.slide, deckcraft_render::SlidePart::Backdrop, &opts) -} - -/// `top` laid over `back` (both premultiplied RGBA of the same size; `top` alone otherwise). -fn over(back: &deckcraft_render::Image, top: &deckcraft_render::Image) -> egui::ColorImage { - if back.width != top.width || back.height != top.height || back.pixels.len() != top.pixels.len() { - return crate::textures::to_color_image(top, false); - } - let px = back - .pixels - .as_chunks::<4>() - .0 - .iter() - .zip(top.pixels.as_chunks::<4>().0) - .map(|(b, t)| { - let k = 255 - t[3] as u32; - let mix = |i: usize| (t[i] as u32 + (b[i] as u32 * k + 127) / 255).min(255) as u8; - Color32::from_rgba_premultiplied(mix(0), mix(1), mix(2), mix(3)) - }) - .collect(); - egui::ColorImage::new([top.width as usize, top.height as usize], px) -} - -#[allow(clippy::too_many_arguments)] -/// Slide `idx` at `step`, `t` seconds into it, `size` pixels, with `hidden` media hidden. With a -/// `backdrop` (of this slide, at this size), only the slide's shapes are rendered, over it. -fn render_frame( - doc: &Presentation, - idx: usize, - tl: Timeline, - step: usize, - t: f64, - size: (u32, u32), - hidden: Vec, - times: &[(ShapeId, f64)], - threads: u16, - plays: &[TriggerPlay], - stamps: &[StepStamp], - backdrop: Option<&deckcraft_render::Image>, -) -> egui::ColorImage { - let f = move |id: ShapeId| { - let mut st = state_for(&tl, id, step, t, plays, stamps); - if hidden.contains(&id) { - st.visible = false; - } - st - }; - let opts = RenderOpts { - scale: size.0 as f64 / doc.slide_size.width.max(1.0), - size: Some(size), - state: Some(&f), - gif_times: times, - threads, - ..Default::default() - }; - match backdrop { - Some(back) => over(back, &deckcraft_render::render_slide_part(doc, idx, deckcraft_render::SlidePart::Content, &opts)), - None => crate::textures::to_color_image(&deckcraft_render::render_slide(doc, idx, &opts), false), - } -} - -/// Render only the slide's own shapes except `skip`, over transparency: what is stacked above a -/// playing video, to be drawn over the video frame. -#[allow(clippy::too_many_arguments)] -fn render_overlay( - doc: &Presentation, - idx: usize, - tl: Timeline, - step: usize, - t: f64, - size: (u32, u32), - hidden: Vec, - skip: &[ShapeId], - times: &[(ShapeId, f64)], - threads: u16, - plays: &[TriggerPlay], - stamps: &[StepStamp], -) -> egui::ColorImage { - let f = move |id: ShapeId| { - let mut st = state_for(&tl, id, step, t, plays, stamps); - if hidden.contains(&id) { - st.visible = false; - } - st - }; - let opts = RenderOpts { - scale: size.0 as f64 / doc.slide_size.width.max(1.0), - size: Some(size), - state: Some(&f), - skip, - gif_times: times, - threads, - ..Default::default() - }; - let img = deckcraft_render::render_slide_part(doc, idx, deckcraft_render::SlidePart::Content, &opts); - crate::textures::to_color_image(&img, false) -} - -/// The shapes of slide `idx` to leave out when drawing what is stacked above the lowest of the -/// playing `videos` (that video, everything below it and the other videos). Empty when nothing -/// is above a video: the video frame is then painted last. -fn below_playing_video(doc: &Presentation, idx: usize, videos: &[ShapeId]) -> Vec { - let Some(slide) = doc.slides.get(idx) else { return vec![] }; - let Some(first) = slide.shapes.iter().position(|s| videos.contains(&s.id)) else { return vec![] }; - if slide.shapes.iter().skip(first + 1).all(|s| videos.contains(&s.id)) { - return vec![]; - } - slide.shapes.iter().enumerate().filter(|(i, s)| *i <= first || videos.contains(&s.id)).map(|(_, s)| s.id).collect() -} - -/// Media on slide `idx` hidden until it plays, as it is when the slide is entered. -fn hidden_on_entry(doc: &Presentation, idx: usize) -> Vec { - crate::media::slide_media(doc, idx).into_iter().filter(|(_, _, clip)| clip.hide_while_not_playing).map(|m| m.0).collect() -} - -/// Start rendering slide `next`'s first frame on another thread. `None` on the web (no threads). -fn start_prefetch(doc: &std::sync::Arc, next: usize, size: (u32, u32)) -> Option { - if cfg!(target_arch = "wasm32") { - return None; - } - let hidden = hidden_on_entry(doc, next); - let key = Key { slide: next, step: 0, size, doc: std::sync::Arc::as_ptr(doc) as usize, hidden: hidden.clone(), gif: 0, plays: vec![] }; - let (tx, rx) = std::sync::mpsc::channel(); - let doc = doc.clone(); - // Leave a core for the UI thread. - let threads = render_threads().saturating_sub(1).max(1); - let spawned = std::thread::Builder::new().name("show-prefetch".into()).spawn(move || { - let tl = ShowState::timeline(&doc, next); - let _ = tx.send(render_frame(&doc, next, tl, 0, 0.0, size, hidden, &[], threads, &[], &[], None)); - }); - spawned.ok().map(|_| Prefetch { key, rx }) -} - struct Transition { old: Option, /// The slide left behind (Morph matches its shapes) and the Morph frame texture. @@ -673,1711 +554,5 @@ fn show_fills_screen(reading: bool, show_type: &str) -> bool { !reading && show_type != "browsed" } -fn fit(avail: Rect, doc: &Presentation) -> Rect { - // Loaded sizes are repaired, but a zero side here would make every rectangle NaN. - let side = |v: f64| if v.is_finite() { (v as f32).max(1.0) } else { 1.0 }; - let (sw, sh) = (side(doc.slide_size.width), side(doc.slide_size.height)); - let s = (avail.width() / sw).min(avail.height() / sh).max(0.0); - Rect::from_center_size(avail.center(), vec2(sw * s, sh * s)) -} - -/// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective -/// cards subdivided so their pictures stay straight) with per-vertex opacity. -/// Where presenter view's "Next" preview goes: the column's width below its label, kept inside -/// the top 60% of the column. A portrait slide used to get the full width and a height many -/// times the window (a white slab over the notes and past the window edge). -fn next_rect(right: Rect, doc: &Presentation) -> Rect { - let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6)); - Rect::from_min_size(area.min, fit(area, doc).size()) -} - -fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { - let mut m = match tex { - Some(id) => Mesh::with_texture(id), - None => Mesh::default(), - }; - let (verts, tris) = deckcraft_anim::layer_mesh(l); - for v in &verts { - let a = if v.a.is_finite() { v.a.clamp(0.0, 1.0) } else { 0.0 }; - let col = if tex.is_some() { Color32::from_white_alpha((a * 255.0).round() as u8) } else { color.gamma_multiply(a as f32) }; - let p = pos2(rect.min.x + v.pos[0] as f32 * rect.width(), rect.min.y + v.pos[1] as f32 * rect.height()); - m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(v.uv[0] as f32, v.uv[1] as f32), color: col }); - } - for t in tris { - m.add_triangle(t[0], t[1], t[2]); - } - m -} - -/// Render the backgrounds and contents of a transition's two slides into textures, once. -fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, to: usize, size: (u32, u32)) -> [Option; 4] { - use deckcraft_render::SlidePart; - let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; - let tex = |i: usize, part: SlidePart, name: &str| { - let img = deckcraft_render::render_slide_part(doc, i, part, &opts); - crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)) - }; - [ - from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), - from.map(|i| tex(i, SlidePart::Content, "trans-old-content")), - Some(tex(to, SlidePart::Backdrop, "trans-new-back")), - Some(tex(to, SlidePart::Content, "trans-new-content")), - ] -} - -/// Read the current viewport's show keys before its widgets can consume them. Both the -/// audience window and Presenter View use this path, including animation steps and end-of-show. -/// `presenter` is true for Presenter View, where G opens that window's own grid (#1130). -fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, presenter: bool) -> bool { - let keys: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) - .collect() - }); - let mut keep = true; - let mut actions = vec![]; - for (k, m) in keys { - let handled = if grid_open(show, presenter) && !matches!(k, egui::Key::Escape | egui::Key::Minus) { - // See All Slides takes no other keys until a slide is chosen. - if k == egui::Key::G && plain(m) { - close_grid(show, presenter); - true - } else { - false - } - } else { - navigation_key(show, doc, k, m, presenter, &mut keep, &mut actions) || tool_key(show, k, m, now, presenter) - }; - if !handled { - continue; - } - // The same key must not also activate a focused presenter button or be handled again - // by an embedded presenter viewport. - ctx.input_mut(|i| i.consume_key(m, k)); - ctx.request_repaint_of(egui::ViewportId::ROOT); - } - for action in actions { - if !show.apply(action, doc, now) { - keep = false; - } - } - keep -} - -/// Whether the grid that the presenter key G opens is showing in this viewport. -fn grid_open(show: &Show, presenter: bool) -> bool { - if presenter { show.presenter_grid } else { show.grid } -} - -/// Closes the grid that G opens in this viewport. -fn close_grid(show: &mut Show, presenter: bool) { - if presenter && show.presenter_grid { - show.presenter_grid = false; - } else { - show.grid = false; - } -} - -/// Keys that move through the show or end it. False when `k` isn't one of them. -fn navigation_key( - show: &mut Show, - doc: &Presentation, - k: egui::Key, - m: egui::Modifiers, - presenter: bool, - keep: &mut bool, - actions: &mut Vec, -) -> bool { - use egui::Key::*; - match k { - Escape | Minus => { - let cancelled = k == Escape && show.clear_goto(); - if !cancelled && !show.back_out(presenter) { - *keep = false; - } - } - ArrowRight | ArrowDown | Space | PageDown | N => { - if show.ended { - *keep = false; - } else { - actions.push(show.state.next(doc)); - } - } - Enter => { - if !show.goto.is_empty() { - if let Some(i) = show.take_goto() - && i < doc.slides.len() - { - actions.push(show.state.goto(doc, i)); - } - } else if show.ended { - *keep = false; - } else { - actions.push(show.state.next(doc)); - } - } - Backspace if !show.goto.is_empty() => { - show.goto.pop(); - } - ArrowLeft | ArrowUp | Backspace | PageUp => { - show.ended = false; - actions.push(show.state.prev(doc)); - } - P if m.command => show.set_pen(!show.pen), - P => { - show.ended = false; - actions.push(show.state.prev(doc)); - } - Home => { - if let Some(i) = show.state.first_index(doc) { - actions.push(show.state.goto(doc, i)); - } - } - End => { - if let Some(i) = show.state.last_index(doc) { - actions.push(show.state.goto(doc, i)); - } - } - _ => return false, - } - true -} - -/// Pen, laser, blanking, zoom, the timer, note size and typed slide numbers. False when `k` -/// isn't one of them. -fn tool_key(show: &mut Show, k: egui::Key, m: egui::Modifiers, now: f64, presenter: bool) -> bool { - use egui::Key::*; - match k { - L if m.command => show.set_laser(!show.laser), - A if m.command => show.arrow(), - B | Period => toggle_blank(show, Color32::BLACK), - W | Comma => toggle_blank(show, Color32::WHITE), - E => show.strokes.clear(), - G if plain(m) => show.open_grid(presenter), - Z if plain(m) => show.zoom_key(), - T if plain(m) => show.timer.toggle(now), - R if plain(m) => show.timer.reset(now), - OpenBracket if plain(m) => show.nudge_notes(-NOTES_STEP), - CloseBracket if plain(m) => show.nudge_notes(NOTES_STEP), - Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { - if let Some(d) = k.name().chars().next() { - show.push_slide_number(d, now); - } - } - _ => return false, - } - true -} - -/// Draw and advance the active show, handling hidden frames, timing, input, transitions, and -/// presenter/reading presentation. -pub fn ui(app: &mut SlideApp, ui: &mut Ui) { - let ctx = ui.ctx().clone(); - let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { - app.show = None; - return; - }; - let now = ctx.input(|i| i.time); - let Some(mut show) = app.show.take() else { return }; - if !viewport_visible(&ctx) { - // A forced hidden paint (for example a pending screenshot) draws the current frame; - // the show clocks are advanced by logic, not by this draw, so it must not advance them a - // second time. Keep drawing the last frame rather than returning a blank. - if show.started { - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); - let painter = ui.painter_at(full); - painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); - if let Some((_, tex)) = &show.tex { - painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); - } - } - app.show = Some(show); - return; - } - show.observe_visible_frame(now, web_time::Instant::now()); - if !show.started { - show.started = true; - show.slide_start = now; - show.step_start = now; - // The first step is settled at the show's own start: every clock origin is a real frame - // time, so hiding and resuming shifts all of them by the same interval. - show.step_end = now; - show.show_start = now; - show.timer = PresenterTimer::start(now); - show.last_move = now; - if show_fills_screen(show.reading, &doc.show.show_type) { - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); - } - show.after_enter(&doc, now); - } - let mut keep = keyboard(&ctx, &mut show, &doc, now, false); - let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); - if moved { - show.last_move = now; - } - let mut actions: Vec = vec![]; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); - let pointer = ctx.input(|i| i.pointer.interact_pos()); - show.expire_goto(now); - // The clock of the triggered animations runs before anything reacts to the pointer. - show.tick_triggers(&doc, now); - let on_slide = !show.grid - && !show.pen - && !show.laser - && !show.ended - && show.blank.is_none() - && show.zoom.is_none() - && !show.zoom_arm - && !show.reading_bar_hit(&ctx) - && !pointer_on_media_bar(&ctx, ui, &doc, &show, now); - // The button is drawn only while the pointer is awake; a hidden button takes no click. - let gif_click = if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) { - gif_hit(&ctx, ui, &show, doc_uid, &doc, app) - } else { - None - }; - let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; - let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; - let pointing = under.as_ref().is_some_and(|u| u.pick.is_some()); - if let Some(id) = gif_click { - let _ = app.run("media.gifPlay", json!({"id": id})); - } else if let Some((id, clip)) = media_click { - app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); - } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) { - let last = show.last_viewed; - match show.state.follow_link(&doc, &l, last) { - LinkJump::Show(a) => actions.push(a), - LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - LinkJump::Exit => keep = false, - LinkJump::None => {} - } - } else if show.grid && clicked { - let aspect = slide_aspect(&doc); - if let Some(p) = pointer - && let Some(n) = see_all_hit(full, grid_indices(doc.slides.len()).len(), aspect, p) - && let Some(&i) = grid_indices(doc.slides.len()).get(n) - { - show.grid = false; - show.blank = None; - show.zoom = None; - show.zoom_arm = false; - actions.push(show.state.goto(&doc, i)); - } - } else if clicked && show.zoom_arm && !show.reading_bar_hit(&ctx) { - if let Some(p) = pointer - && let Some((nx, ny)) = norm_on_slide(srect, unit_uv(), p) - { - show.zoom = Some(Zoom::at(nx, ny)); - show.zoom_arm = false; - } - } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { - show.dismiss_zoom(); - } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) { - if show.ended { - keep = false; - } else if show.blank.is_some() { - show.blank = None; - } else { - // A shape with an action or a triggered animation, or the background. - let none = Under::default(); - match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) { - Click::Show(a) => actions.push(a), - Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - Click::Exit => keep = false, - Click::Animated | Click::None => {} - } - } - } - if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm { - actions.push(show.state.prev(&doc)); - } - // Ctrl+wheel (Cmd+wheel on a Mac) zooms around the pointer, as in PowerPoint (#1286). egui - // keeps it out of `smooth_scroll_delta`, so it does not also turn the page below. - let line_points = ctx.options(|o| o.input_options.line_scroll_speed); - if !show.grid - && !show.ended - && show.blank.is_none() - && let Some(p) = pointer - && let Some(lines) = ctx.input(|i| wheel_zoom_lines(&i.events, line_points)) - { - show.wheel_zoom(srect, p, lines); - } - // A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick - // there stays on the slide. Pinch zoom is not a show gesture. - if !show.pen - && let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta)) - && now - show.last_swipe > 0.35 - { - show.last_swipe = now; - if next { - if show.ended { - keep = false; - } else { - actions.push(show.state.next(&doc)); - } - } else { - show.ended = false; - actions.push(show.state.prev(&doc)); - } - } - // Apply input first: a step started by this click must restart the clock before it is read. - for a in actions.drain(..) { - if !show.apply(a, &doc, now) { - keep = false; - } - } - // Step clock, shared with the hidden logic tick. - if !show.queue_clock_actions(&doc, now, &mut actions) { - keep = false; - } - for a in actions { - if !show.apply(a, &doc, now) { - keep = false; - } - } - if show.state.playing { - let step = show.state.step; - show.cue_media(&doc, step, now - show.step_start); - } - show_media(app, &mut show, doc_uid, &doc); - run_media_cues(app, &mut show, doc_uid, &doc); - // Draw. - let painter = ui.painter_at(full); - painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); - let ppp = ctx.pixels_per_point(); - // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit - // (drawn stretched to the screen). - let size = crate::textures::fit_size( - ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), - crate::textures::max_side(&ctx), - ); - if show.ended { - painter.text( - pos2(full.center().x, full.min.y + 40.0), - Align2::CENTER_CENTER, - "End of slide show, click to exit.", - theme::font(18.0), - Color32::from_gray(200), - ); - } else { - let idx = show.state.slide; - let step = show.state.step; - // A main step or a triggered animation is running: the picture changes every frame. - let animating = show.state.playing || show.state.trigger_playing(); - let t = if show.state.playing { now - show.step_start } else { 0.0 }; - let plays = show.state.trigger_plays(); - let stamps = show.state.trigger_stamps(); - let media = crate::media::slide_media(&doc, idx); - let hidden: Vec = media - .iter() - .filter(|(id, _, clip)| { - clip.hide_while_not_playing && !app.media.status(*id).is_some_and(|s| s.state != deckcraft_media::PlayState::Ended) - }) - .map(|m| m.0) - .collect(); - let gifs = app.media.gifs(doc_uid, &doc, idx, now, &app.session.gif_paused); - let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); - crate::media::gif_repaint(&ctx, &gifs); - let gif = crate::media::gif_key(&gifs); - let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); - let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; - let back_key = BackKey { slide: idx, size, doc: key.doc }; - let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { - show.tex.as_ref().map(|(_, t)| t.clone()) - } else { - // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). - let ready = match show.prefetch.take() { - Some(p) if p.key == key && !animating => p.rx.recv().ok(), - _ => None, - }; - let ci = match ready { - Some(ci) => ci, - None => { - let tl = show.timeline(&doc).clone(); - // Animating: the backdrop doesn't change, only the shapes over it are drawn. - let back = animating.then(|| show.backdrop(&doc, back_key)); - render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps, back.as_deref()) - } - }; - let tex = match show.tex.take() { - Some((_, mut h)) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "show", ci), - }; - show.tex = Some((key.clone(), tex.clone())); - Some(tex) - }; - // While this slide rests, render the next one's first frame (not one with GIFs, whose - // frame depends on when it is entered). - // A slide with animations gets its backdrop ready while it rests, so they start smoothly. - if !animating && !show.timeline(&doc).is_empty() { - show.prefetch_backdrop(&doc, back_key); - } - if !animating && show.trans.is_none() && !show.preview_only { - let next = show.state.peek_next(&doc); - let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); - if let Some(n) = wanted - && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) - { - show.prefetch = start_prefetch(&doc, n, size); - } - } - let mut drew = false; - let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); - let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None }; - let morph = match (&show.trans, tl) { - (Some(tr), Some(tl)) => { - let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); - let hidden_b = |id: ShapeId| { - let st = state_for(&tl, id, 0, 0.0, &[], &[]); - !st.visible || st.opacity <= 0.0 || hidden.contains(&id) - }; - tr.from - .and_then(|i| doc.slides.get(i).map(|a| (i, a))) - .zip(doc.slides.get(idx)) - .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b))) - } - _ => None, - }; - if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) { - let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; - let threads = - if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = - RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() }; - let text: Vec = m - .text - .iter() - .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }) - .collect(); - let paths: Vec = m - .paths - .iter() - .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) - .collect(); - let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts); - let ci = crate::textures::to_color_image(&img, false); - let frame = match tr.morph.take() { - Some(mut h) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "morph", ci), - }; - painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - tr.morph = Some(frame); - drew = true; - ctx.request_repaint(); - } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { - let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); - if p < 1.0 { - // Slides split into background and content are rendered once per transition; - // every frame only composites cached textures. - if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { - tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size)); - } - let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); - let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); - let parts = [part(0), part(1), part(2), part(3)]; - for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { - let old_or_black = |t: Option<&TextureHandle>| match t { - Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), - None => quad_mesh(None, srect, &l, Color32::BLACK), - }; - let mesh = match l.source { - Source::Old => old_or_black(tr.old.as_ref()), - Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), - Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), - Source::White => quad_mesh(None, srect, &l, Color32::WHITE), - Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), - Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), - Source::OldContent => match &parts[1] { - Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), - None => continue, - }, - Source::NewContent => match &parts[3] { - Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), - None => continue, - }, - }; - painter.add(egui::Shape::mesh(mesh)); - } - drew = true; - ctx.request_repaint(); - } - } - if !drew { - show.trans = None; - if let Some(tex) = &tex { - painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); - } - // Playing video: the current frame in its box, or full screen. - let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); - let uv = show.zoom_uv(); - let mut framed: Vec = vec![]; - let mut full_screen = false; - for (id, x, clip) in &media { - if !clip.video || hidden.contains(id) { - continue; - } - let r = if clip.full_screen && app.media.is_playing(*id) { - full_screen = true; - full - } else { - slide_box(srect, uv, sw, sh, x.x as f32, x.y as f32, x.w as f32, x.h as f32) - }; - if crate::media::paint_frame(&painter, &app.media, *id, r, clip.crop) { - framed.push(*id); - } - } - // Shapes stacked above a playing video show over it, as in PowerPoint (#1005); a video - // playing full screen covers the whole slide. - let skip = if full_screen { vec![] } else { below_playing_video(&doc, idx, &framed) }; - if skip.is_empty() { - show.overlay = None; - } else { - let fresh = !animating && show.overlay.as_ref().is_some_and(|(k, s, _)| *k == key && *s == skip); - if !fresh { - let tl = show.timeline(&doc).clone(); - let ci = render_overlay(&doc, idx, tl, step, t, size, hidden.clone(), &skip, ×, render_threads(), &plays, &stamps); - let handle = match show.overlay.take() { - Some((_, _, mut h)) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "show-overlay", ci), - }; - show.overlay = Some((key.clone(), skip, handle)); - } - if let Some((_, _, h)) = &show.overlay { - painter.image(h.id(), srect, uv, Color32::WHITE); - } - } - // Play/pause on the animated GIF under the pointer, and the audio/video bar. - // Slide Show ▸ Show Media Controls hides both (the click still plays a clip). - // Not while zoomed: the controls sit where the whole slide would be. - if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { - let k = srect.width() / sw.max(1.0); - if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { - for g in &gifs { - let r = on_screen(srect, k, &g.rect); - if r.contains(p) { - let b = crate::media::gif_button_rect(r); - crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p)); - } - } - } - if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) { - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - let action = { - let status = app.media.status(id); - crate::media::control_bar(ui, bar, status.as_ref(), &clip) - }; - let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); - match action { - Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, &doc, id, &clip, t, crate::media::Owner::Show), - Some(crate::media::BarAction::Nudge(dt)) => { - app.media.seek(doc_uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show) - } - None => {} - } - } - } - } - if animating { - ctx.request_repaint(); - } - } - if let Some(c) = show.blank { - painter.rect_filled(full, CornerRadius::ZERO, c); - } - if !show.goto.is_empty() { - let label = format!("Go to slide {}", show.goto); - let w = (28.0 + label.len() as f32 * 10.0).clamp(160.0, full.width() - 32.0); - let r = Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 96.0), vec2(w, 36.0)); - painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(190)); - painter.text(r.center(), Align2::CENTER_CENTER, label, theme::font(18.0), Color32::WHITE); - ctx.request_repaint_after(std::time::Duration::from_millis(200)); - } - // Pen. - let resp = ui.interact(full, ui.scope_id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() }); - if show.pen { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Pen")); - } - if show.pen { - if resp.drag_started() { - show.strokes.push(vec![]); - } - let uv = show.zoom_uv(); - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() - && let Some((nx, ny)) = norm_on_slide(srect, uv, p) - && let Some(s) = show.strokes.last_mut() - { - s.push(pos2(nx, ny)); - } - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - } else if show.laser { - ctx.set_cursor_icon(egui::CursorIcon::None); - } else if show.zoom_arm || show.grid { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - } else if !show.pointer_awake(now) && !show.reading { - // Idle first: a still pointer hides also over a clickable shape, such as a playing clip. - ctx.set_cursor_icon(egui::CursorIcon::None); - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - } else if pointing { - ctx.set_cursor_icon(egui::CursorIcon::PointingHand); - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - } - let pen_col = theme::to_color32(doc.show.pen_color); - let uv = show.zoom_uv(); - for s in &show.strokes { - let pts: Vec = s.iter().map(|q| slide_to_screen(srect, uv, q.x, q.y)).collect(); - painter.add(egui::Shape::line(pts, Stroke::new(3.0, pen_col))); - } - if show.laser - && !show.ended - && let Some(p) = ctx.input(|i| i.pointer.hover_pos()) - { - let c = theme::to_color32(doc.show.laser_color); - painter.circle_filled(p, 16.0, Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 70)); - painter.circle_filled(p, 5.5, c); - } - if show.zoom_arm && !show.grid { - painter.text( - pos2(full.center().x, full.min.y + 36.0), - Align2::CENTER_CENTER, - "Click the slide to zoom in. Esc cancels.", - theme::font(16.0), - Color32::WHITE, - ); - } - // Reading view bar / on-screen controls. - if show.reading { - let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max); - painter.rect_filled(bar, CornerRadius::ZERO, Color32::from_gray(32)); - painter.text( - pos2(bar.min.x + 14.0, bar.center().y), - Align2::LEFT_CENTER, - format!("Slide {} of {}", show.state.slide + 1, doc.slides.len()), - theme::font(12.0), - Color32::from_gray(200), - ); - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(bar.max.x - 260.0, bar.min.y), bar.max)) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - if child.button("Close").clicked() { - keep = false; - } - if child.button("▶").clicked() { - let a = show.state.next(&doc); - if !show.apply(a, &doc, now) { - keep = false; - } - } - if child.button("◀").clicked() { - let a = show.state.prev(&doc); - show.apply(a, &doc, now); - } - } else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) { - let bar = tool_bar_rect(full); - painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140)); - let mut child = - ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - let b = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).color(Color32::WHITE).size(15.0)).frame(false)); - if b(&mut child, "◀").clicked() { - let a = show.state.prev(&doc); - show.apply(a, &doc, now); - } - if b(&mut child, "▶").clicked() { - let a = show.state.next(&doc); - if !show.apply(a, &doc, now) { - keep = false; - } - } - if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() { - show.set_pen(!show.pen); - } - if b(&mut child, if show.laser { "◉ on" } else { "◉" }).clicked() { - show.set_laser(!show.laser); - } - if b(&mut child, "Black").clicked() { - toggle_blank(&mut show, Color32::BLACK); - } - if b(&mut child, "White").clicked() { - toggle_blank(&mut show, Color32::WHITE); - } - if b(&mut child, "All").clicked() { - show.grid = !show.grid; - } - if b(&mut child, show.zoom_label()).clicked() { - show.zoom_key(); - } - if b(&mut child, "End").clicked() { - keep = false; - } - ctx.request_repaint_after(std::time::Duration::from_millis(300)); - } - if show.grid && !show.ended { - paint_grid(&painter, &ctx, &mut show, &doc, full); - } - if show.rehearse { - let t = now - show.slide_start; - let total = now - show.show_start; - let r = Rect::from_min_size(pos2(full.min.x + 12.0, full.min.y + 12.0), vec2(220.0, 30.0)); - painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(160)); - painter.text( - r.center(), - Align2::CENTER_CENTER, - format!("Slide {:02}:{:04.1} Total {:02}:{:02}", (t / 60.0) as u32, t % 60.0, (total / 60.0) as u32, (total % 60.0) as u32), - theme::font(13.0), - Color32::WHITE, - ); - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } - // Presenter view in a second window. - if keep && show.presenter && !show.reading { - presenter_window(&ctx, &mut show, &doc, now, &mut keep); - } - if keep { - app.show = Some(show); - } else { - app.media.stop_owned(crate::media::Owner::Show); - if !show.reading { - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); - } - if show.rehearse { - if let Some((_, tl)) = &show.timeline { - let _ = tl; - } - show.timings.push((show.state.slide, now - show.slide_start)); - for (i, secs) in show.timings.clone() { - let _ = app.run("transition.timing", json!({"index": i, "after": (secs * 1000.0).round() as u64})); - } - app.set_status("Slide timings recorded."); - } - let last = show.state.slide; - let _ = app.run("slide.go", json!({"index": last})); - } -} - -/// The media-host voice for slide transition sounds (no shape has this id). -pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); - -/// Start the media of a newly shown slide: media from the previous slide stops unless it plays -/// across slides; media set to start automatically plays. Everything stops at the end. -fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { - let cur = if show.ended { None } else { Some(show.state.slide) }; - if show.media_slide == cur { - return; - } - show.media_slide = cur; - let trans = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()); - let sound = trans.and_then(|t| t.sound); - let sound_loop = trans.is_some_and(|t| t.sound_loop); - // [Stop Previous Sound] ends a transition sound still playing. - let stop_previous = trans.is_some_and(|t| t.stop_sound); - let keep: Vec = match cur { - Some(i) => app - .media - .ids() - .into_iter() - .filter(|id| app.media.clip(*id).is_some_and(|c| crate::media::carries_over(c, doc, *id, i))) - // A transition sound plays to its end, as in PowerPoint, unless the new slide has its own. - .chain((sound.is_none() && !stop_previous).then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) - .collect(), - None => vec![], - }; - for id in app.media.ids() { - if !keep.contains(&id) { - app.media.stop(id); - } - } - if let Some(media) = sound { - // Loop Until Next Sound: it repeats until another slide's sound or Stop Previous Sound. - let clip = deckcraft_model::MediaClip { media, volume: 1.0, loop_play: sound_loop, ..Default::default() }; - app.media.play(doc_uid, doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); - } - if let Some(i) = cur { - for (id, _, clip) in crate::media::slide_media(doc, i) { - if clip.autoplay && !keep.contains(&id) { - app.media.play(doc_uid, doc, id, &clip, None, crate::media::Owner::Show); - } - } - } -} - -/// Carry out the media effects the show's steps set off: Play starts from the beginning unless -/// the clip is already playing, Pause toggles, Stop stops. -fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { - for (id, effect) in std::mem::take(&mut show.media_cues) { - let Some(clip) = crate::media::find_clip(doc, id, Some(show.state.slide)) else { continue }; - match effect.as_str() { - "pause" => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), - "stop" => app.media.stop(id), - // Already playing (it also starts automatically): leave it running. - _ if app.media.is_playing(id) => {} - _ => app.media.play(doc_uid, doc, id, &clip, Some(0.0), crate::media::Owner::Show), - } - } -} - -/// Slide rectangle the show draws into (the reading bar keeps its strip). -fn shown_slide_rect(ui: &Ui, show: &Show, doc: &Presentation) -> Rect { - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - fit(avail, doc) -} - -/// Playback bar for one clip: along the bottom inside a large shape, just under a small icon. -fn media_bar_rect(shape: Rect) -> Rect { - let h = 26.0f32; - let w = shape.width().clamp(160.0, 420.0); - if shape.height() >= h + 16.0 && shape.width() >= 160.0 { - Rect::from_min_size(pos2(shape.min.x + 4.0, shape.max.y - h - 4.0), vec2((shape.width() - 8.0).min(w), h)) - } else { - Rect::from_min_size(pos2(shape.center().x - w / 2.0, shape.max.y + 4.0), vec2(w, h)) - } -} - -fn slide_media_bars(srect: Rect, doc: &Presentation, slide: usize) -> Vec<(ShapeId, deckcraft_model::MediaClip, Rect, Rect)> { - let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - crate::media::slide_media(doc, slide) - .into_iter() - .map(|(id, x, clip)| { - let r = Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)); - let bar = media_bar_rect(r); - (id, clip, r, bar) - }) - .collect() -} - -/// The clip whose bar or shape is under the pointer, when Show Media Controls is on. -fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect: Rect) -> Option<(ShapeId, deckcraft_model::MediaClip, Rect)> { - if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() { - return None; - } - let p = ctx.input(|i| i.pointer.hover_pos())?; - let items = slide_media_bars(srect, doc, show.state.slide); - if let Some((id, clip, _, bar)) = items.iter().rev().find(|(_, _, _, bar)| bar.contains(p)) { - return Some((*id, clip.clone(), *bar)); - } - let (id, clip, _, bar) = items.iter().rev().find(|(_, _, r, _)| r.contains(p))?; - Some((*id, clip.clone(), *bar)) -} - -/// True when the pointer is on a visible playback bar, so that click must not advance the show. -fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show, now: f64) -> bool { - if !doc.show.show_media_controls || !show.pointer_awake(now) || show.pen || show.ended || show.blank.is_some() { - return false; - } - let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false }; - let srect = shown_slide_rect(ui, show, doc); - slide_media_bars(srect, doc, show.state.slide).iter().any(|(_, _, _, bar)| bar.contains(p)) -} - -/// The media shape under the pointer on the shown slide. -fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> Option<(ShapeId, deckcraft_model::MediaClip)> { - let p = ctx.input(|i| i.pointer.interact_pos())?; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, doc); - let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - crate::media::slide_media(doc, show.state.slide).into_iter().rev().find_map(|(id, x, clip)| { - let r = Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)); - r.contains(p).then_some((id, clip)) - }) -} - -/// What a click on the pointer's spot acts on. -#[derive(Clone)] -enum Pick { - /// A hyperlink in text. - Text(Hyperlink), - /// A shape with an action or a triggered animation. - Shape, -} - -/// The slide's shapes at the pointer, for [`ShowState::click`]. -#[derive(Default)] -struct Under { - shapes: Vec, - /// Whether each of `shapes` is currently shown (an animation may have hidden it). - visible: Vec, - pick: Option, -} - -impl Under { - fn visible(&self, id: ShapeId) -> bool { - self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) - } -} - -/// What is under the pointer on the shown slide: the slide's shapes with their animation state, the -/// topmost of them that does something when clicked, and a text hyperlink on it. Only shapes that -/// are shown can be clicked, and a shape without an action hides the ones beneath it. -fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation, now: f64) -> Option { - let p = ctx.input(|i| i.pointer.hover_pos())?; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, doc); - let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64; - let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k); - let i = show.state.slide; - if show.links.as_ref().is_none_or(|(s, ..)| *s != i) { - let placed = deckcraft_render::place_slide(doc, i); - show.links = Some((i, placed.links, placed.shapes)); - } - show.timeline(doc); - let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; - let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1; - let hits: Vec = shapes - .iter() - .map(|s| HitShape { - id: s.id, - parent: s.parent, - is_group: s.is_group, - link: s.link.as_ref().map(|l| l.action.clone()), - under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1), - }) - .collect(); - let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps()); - let t = if show.state.playing { now - show.step_start } else { 0.0 }; - let visible: Vec = shapes - .iter() - .map(|s| { - let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); - a.visible && a.opacity > 0.0 - }) - .collect(); - let mut under = Under { shapes: hits, visible, pick: None }; - let shown = |id: ShapeId| under.visible(id); - // A hyperlink in text applies when its shape is the one on top. - let top = show.state.top(doc, tl, &under.shapes, &shown); - let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| { - let mut at = top; - (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { - Some(h) if h.id == l.shape => true, - Some(h) => { - at = h.parent; - false - } - None => false, - }) - }); - under.pick = match text { - Some(l) => Some(Pick::Text(l.link.clone())), - None => show.state.target(doc, tl, &under.shapes, &shown).map(|_| Pick::Shape), - }; - Some(under) -} - -impl Show { - /// Advance the clock of the running triggered animations. - fn tick_triggers(&mut self, doc: &Presentation, now: f64) { - self.timeline(doc); - if let Some((_, tl)) = &self.timeline { - self.state.tick(tl, now); - } - } - - /// A click on the slide: the shape under the pointer acts, or the background advances. - fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { - self.timeline(doc); - let last = self.last_viewed; - match &self.timeline { - Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last), - None => Click::None, - } - } -} - -/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. -fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { - Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)) -} - -/// The animated GIF whose play/pause button is under the pointer on the shown slide. -fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, doc: &Presentation, app: &mut SlideApp) -> Option { - let p = ctx.input(|i| i.pointer.interact_pos())?; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, doc); - let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); - gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id) -} - -impl Show { - fn zoom_uv(&self) -> Rect { - self.zoom.map(Zoom::uv).unwrap_or_else(unit_uv) - } - - fn zoom_label(&self) -> &'static str { - if self.zoom.is_some() { - "Zoom on" - } else if self.zoom_arm { - "Zoom…" - } else { - "Zoom" - } - } - - /// G opens See All Slides in the window it was pressed in: Presenter View's grid stays off the audience screen (#1130). - fn open_grid(&mut self, presenter: bool) { - if presenter { - self.presenter_grid = true; - } else { - self.grid = true; - } - } - - /// Z, or the Zoom button: arm a click-to-zoom, or leave a zoom that is already on. - fn zoom_key(&mut self) { - if self.zoom.is_some() || self.zoom_arm { - self.dismiss_zoom(); - } else { - self.zoom_arm = true; - self.pen = false; - } - } - - /// Ctrl/Cmd+wheel: `lines` notches in (positive) or out around the slide point under `p`. - /// Back to the whole slide leaves the zoom. A pointer off the slide does nothing. - fn wheel_zoom(&mut self, srect: Rect, p: Pos2, lines: f32) { - let uv = self.zoom_uv(); - let Some((nx, ny)) = norm_on_slide(srect, uv, p) else { return }; - let from = self.zoom.map_or(1.0, |z| z.scale); - let scale = (from * 2f32.powf(lines / WHEEL_NOTCHES_PER_DOUBLING)).min(WHEEL_ZOOM_MAX); - if !scale.is_finite() { - return; - } - self.zoom_arm = false; - if scale <= 1.0 + 1e-3 { - self.zoom = None; - return; - } - // The new window puts (nx, ny) at the same place on screen as before. - let w = 1.0 / scale; - let fx = (p.x - srect.min.x) / srect.width(); - let fy = (p.y - srect.min.y) / srect.height(); - let x = (nx - fx * w).clamp(0.0, 1.0 - w); - let y = (ny - fy * w).clamp(0.0, 1.0 - w); - self.zoom = Some(Zoom { nx: x + w * 0.5, ny: y + w * 0.5, scale }); - } - - /// Esc closes the See All Slides of the window it was pressed in, then a zoom, and only then leaves the show. - fn back_out(&mut self, presenter: bool) -> bool { - if presenter && self.presenter_grid { - self.presenter_grid = false; - true - } else if !presenter && self.grid { - self.grid = false; - true - } else { - self.dismiss_zoom() - } - } - - /// Esc backs out of a zoom before it leaves the show. True when it did. - fn dismiss_zoom(&mut self) -> bool { - if self.zoom.is_some() || self.zoom_arm { - self.zoom = None; - self.zoom_arm = false; - true - } else { - false - } - } - - pub fn blank_name(&self) -> Option<&'static str> { - match self.blank { - Some(c) if c == Color32::BLACK => Some("black"), - Some(c) if c == Color32::WHITE => Some("white"), - Some(_) => Some("blank"), - None => None, - } - } - - pub fn zoom_view(&self) -> Option<(f32, f32, f32)> { - self.zoom.map(|z| (z.nx, z.ny, z.scale)) - } - - fn push_slide_number(&mut self, digit: char, now: f64) { - if !digit.is_ascii_digit() || self.goto.len() >= GOTO_DIGITS { - return; - } - self.goto.push(digit); - self.goto_at = if now.is_finite() { now } else { 0.0 }; - } - - /// 1-based slide number, or `None` for 0 or a non-number. The buffer is cleared either way. - fn take_goto(&mut self) -> Option { - let raw = std::mem::take(&mut self.goto); - raw.parse::().ok().and_then(|n| n.checked_sub(1)) - } - - fn expire_goto(&mut self, now: f64) { - if self.goto.is_empty() || !now.is_finite() || !self.goto_at.is_finite() { - return; - } - if now - self.goto_at > GOTO_HOLD { - self.goto.clear(); - } - } - - /// Esc drops a number in progress. True when it did. - fn clear_goto(&mut self) -> bool { - if self.goto.is_empty() { - false - } else { - self.goto.clear(); - true - } - } - - fn nudge_notes(&mut self, delta: f32) { - let next = self.notes_px + delta; - if next.is_finite() { - self.notes_px = next.clamp(NOTES_MIN, NOTES_MAX); - } - } - - pub fn showing_value(&self, n_slides: usize, aspect: f32, full: Rect, now: f64) -> serde_json::Value { - let mut v = json!({ - "slide": self.state.slide, - "step": self.state.step, - "ended": self.ended, - "grid": self.grid, - "zoomArm": self.zoom_arm, - "blank": self.blank_name(), - "zoom": self.zoom_view().map(|(x, y, scale)| json!({"x": x, "y": y, "scale": scale})), - "presenterPaused": self.timer.paused(), - "presenterSecs": self.timer.elapsed(now), - "notesPx": self.notes_px, - "custom": self.state.custom_name(), - "goto": if self.goto.is_empty() { None } else { Some(self.goto.clone()) }, - }); - if self.grid { - let slides = grid_indices(n_slides); - let cells = see_all_cells(full, slides.len(), aspect); - v["cells"] = - serde_json::Value::Array(slides.iter().zip(cells).map(|(i, r)| json!({"slide": i, "x": r.center().x, "y": r.center().y})).collect()); - } - v - } - - fn reading_bar_hit(&self, ctx: &egui::Context) -> bool { - // Clicks on the on-screen controls must not also advance. - ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| { - let r = ctx.content_rect(); - (self.reading && p.y > r.max.y - 34.0) || (!self.reading && tool_bar_rect(r).expand(8.0).contains(p)) - }) - } -} - -const GOTO_HOLD: f64 = 3.0; -const GOTO_DIGITS: usize = 4; -const ZOOM_SCALE: f32 = 2.0; -/// Four notches double the zoom, so the wheel passes through the click-zoom's 2× on its way to 4×. -const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0; -/// PowerPoint's slide show magnifier goes up to 400%. -const WHEEL_ZOOM_MAX: f32 = 4.0; -const GRID_CAP: usize = 48; -const TOOL_BAR_W: f32 = 520.0; -const NOTES_DEFAULT: f32 = 18.0; -const NOTES_MIN: f32 = 12.0; -const NOTES_MAX: f32 = 48.0; -const NOTES_STEP: f32 = 4.0; - -/// Presenter-view elapsed time. Pausing freezes the number; the show keeps playing. -#[derive(Clone, Copy)] -struct PresenterTimer { - origin: f64, - frozen: f64, - paused: bool, -} - -impl PresenterTimer { - fn start(now: f64) -> Self { - PresenterTimer { origin: finite_time(now), frozen: 0.0, paused: false } - } - - fn elapsed(self, now: f64) -> f64 { - if self.paused { - return finite_secs(self.frozen); - } - let dt = now - self.origin; - finite_secs(self.frozen + if dt.is_finite() && dt > 0.0 { dt } else { 0.0 }) - } - - fn paused(self) -> bool { - self.paused - } - - fn toggle(&mut self, now: f64) { - if self.paused { - self.origin = finite_time(now); - self.paused = false; - } else { - self.frozen = self.elapsed(now); - self.paused = true; - } - } - - /// Zero the elapsed time. A paused timer stays paused at zero. - fn reset(&mut self, now: f64) { - self.frozen = 0.0; - self.origin = finite_time(now); - } -} - -fn finite_time(now: f64) -> f64 { - if now.is_finite() { now } else { 0.0 } -} - -fn finite_secs(n: f64) -> f64 { - if n.is_finite() && n >= 0.0 { n } else { 0.0 } -} - -fn format_elapsed(secs: f64) -> String { - let s = finite_secs(secs) as u64; - let s = s.min(99 * 3600 + 59 * 60 + 59); - format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) -} - -/// Local wall clock for presenter view. `--:--` where there is no clock (wasm). -fn wall_clock() -> String { - match deckcraft_text::datetime::DateTime::now_local() { - Some(t) if t.hour < 24 && t.minute < 60 => format!("{:02}:{:02}", t.hour, t.minute), - _ => "--:--".to_string(), - } -} - -#[derive(Clone, Copy)] -struct Zoom { - nx: f32, - ny: f32, - scale: f32, -} - -impl Zoom { - fn at(nx: f32, ny: f32) -> Self { - Zoom { nx: finite_unit(nx), ny: finite_unit(ny), scale: ZOOM_SCALE } - } - - fn uv(self) -> Rect { - zoom_uv(self.nx, self.ny, self.scale) - } -} - -fn finite_unit(n: f32) -> f32 { - if n.is_finite() { n.clamp(0.0, 1.0) } else { 0.5 } -} - -fn plain(m: egui::Modifiers) -> bool { - !m.command && !m.ctrl && !m.alt && !m.shift -} - -fn unit_uv() -> Rect { - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) -} - -fn slide_aspect(doc: &Presentation) -> f32 { - let a = doc.slide_size.width as f32 / doc.slide_size.height.max(1.0) as f32; - if a.is_finite() && a > 0.05 { a } else { 16.0 / 9.0 } -} - -/// The part of the slide a zoom shows, in 0–1 texture space. The window stays on the slide. -fn zoom_uv(nx: f32, ny: f32, scale: f32) -> Rect { - let scale = if scale.is_finite() && scale > 1.0 { scale } else { ZOOM_SCALE }; - let w = 1.0 / scale; - if w >= 1.0 { - return unit_uv(); - } - let x = (nx - w * 0.5).clamp(0.0, 1.0 - w); - let y = (ny - w * 0.5).clamp(0.0, 1.0 - w); - Rect::from_min_size(pos2(x, y), vec2(w, w)) -} - -/// The Ctrl/Cmd+wheel movement this frame in wheel lines (up is positive), `None` when there was -/// none. Read from the raw events: egui's `zoom_delta` also carries the trackpad pinch, which is -/// not a show gesture. -fn wheel_zoom_lines(events: &[egui::Event], line_points: f32) -> Option { - let mut lines = None; - for event in events { - if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event - && (modifiers.ctrl || modifiers.command) - { - let along = if delta.x.abs() > delta.y.abs() { delta.x } else { delta.y }; - let n = match unit { - egui::MouseWheelUnit::Point => along / line_points.max(1.0), - egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => along, - }; - *lines.get_or_insert(0.0) += if n.is_finite() { n } else { 0.0 }; - } - } - lines -} - -fn zoom_frame(slide: Rect, uv: Rect) -> Rect { - Rect::from_min_size( - pos2(slide.min.x + uv.min.x * slide.width(), slide.min.y + uv.min.y * slide.height()), - vec2(uv.width() * slide.width(), uv.height() * slide.height()), - ) -} - -fn toggle_blank(show: &mut Show, color: Color32) { - show.blank = if show.blank == Some(color) { None } else { Some(color) }; -} - -fn tool_bar_rect(full: Rect) -> Rect { - let w = TOOL_BAR_W.min((full.width() - 32.0).max(32.0)); - Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 50.0), vec2(w, 34.0)) -} - -fn grid_indices(n: usize) -> Vec { - (0..n.min(GRID_CAP)).collect() -} - -/// Thumbnail rectangles for `n` slides inside `full`, largest 16:9-style thumbs that fit. -fn see_all_cells(full: Rect, n: usize, aspect: f32) -> Vec { - if n == 0 || full.width() < 16.0 || full.height() < 16.0 { - return vec![]; - } - let aspect = if aspect.is_finite() && aspect > 0.05 { aspect } else { 16.0 / 9.0 }; - let margin = 28.0; - let gap = 16.0; - let label_h = 22.0; - let inner = full.shrink(margin); - let inner = Rect::from_min_max(pos2(inner.min.x, inner.min.y + 36.0), inner.max); - if inner.width() < 8.0 || inner.height() < 8.0 { - return vec![]; - } - let mut best: Vec = vec![]; - let mut best_area = 0.0_f32; - for cols in 1..=n.min(8) { - let rows = n.div_ceil(cols); - let cell_w = (inner.width() - gap * (cols.saturating_sub(1) as f32)) / cols as f32; - let cell_h = (inner.height() - gap * (rows.saturating_sub(1) as f32)) / rows as f32; - if cell_w < 8.0 || cell_h < label_h + 8.0 { - continue; - } - let thumb_max_h = cell_h - label_h; - let mut tw = cell_w; - let mut th = tw / aspect; - if th > thumb_max_h { - th = thumb_max_h; - tw = th * aspect; - } - if tw < 8.0 || th < 8.0 { - continue; - } - let area = tw * th; - if area <= best_area { - continue; - } - best_area = area; - best = (0..n) - .map(|i| { - let c = i % cols; - let r = i / cols; - let ox = inner.min.x + c as f32 * (cell_w + gap) + (cell_w - tw) * 0.5; - let oy = inner.min.y + r as f32 * (cell_h + gap); - Rect::from_min_size(pos2(ox, oy), vec2(tw, th)) - }) - .collect(); - } - best -} - -fn see_all_hit(full: Rect, n: usize, aspect: f32, p: Pos2) -> Option { - see_all_cells(full, n, aspect).into_iter().position(|r| r.expand2(vec2(4.0, 18.0)).contains(p)) -} - -fn norm_on_slide(srect: Rect, uv: Rect, p: Pos2) -> Option<(f32, f32)> { - if srect.width() <= 1.0 || srect.height() <= 1.0 || !srect.contains(p) { - return None; - } - let u = (p.x - srect.min.x) / srect.width(); - let v = (p.y - srect.min.y) / srect.height(); - let nx = uv.min.x + u * uv.width(); - let ny = uv.min.y + v * uv.height(); - (nx.is_finite() && ny.is_finite()).then_some((nx.clamp(0.0, 1.0), ny.clamp(0.0, 1.0))) -} - -fn slide_to_screen(srect: Rect, uv: Rect, nx: f32, ny: f32) -> Pos2 { - let u = (nx - uv.min.x) / uv.width().max(1e-4); - let v = (ny - uv.min.y) / uv.height().max(1e-4); - pos2(srect.min.x + u * srect.width(), srect.min.y + v * srect.height()) -} - -fn slide_box(srect: Rect, uv: Rect, slide_w: f32, slide_h: f32, x: f32, y: f32, w: f32, h: f32) -> Rect { - let a = slide_to_screen(srect, uv, x / slide_w.max(1.0), y / slide_h.max(1.0)); - let b = slide_to_screen(srect, uv, (x + w) / slide_w.max(1.0), (y + h) / slide_h.max(1.0)); - Rect::from_min_max(a, b) -} - -fn paint_grid(painter: &egui::Painter, ctx: &egui::Context, show: &mut Show, doc: &Presentation, full: Rect) { - let slides = grid_indices(doc.slides.len()); - let aspect = slide_aspect(doc); - let cells = see_all_cells(full, slides.len(), aspect); - painter.rect_filled(full, CornerRadius::ZERO, Color32::from_gray(16)); - painter.text(pos2(full.center().x, full.min.y + 28.0), Align2::CENTER_CENTER, "All slides", theme::font(20.0), Color32::WHITE); - if doc.slides.len() > GRID_CAP { - painter.text( - pos2(full.center().x, full.min.y + 50.0), - Align2::CENTER_CENTER, - format!("First {GRID_CAP} slides"), - theme::font(13.0), - Color32::from_gray(180), - ); - } - let budget = if slides.len() <= 12 { 12 } else { 4 }; - let mut made = 0; - for (i, rect) in slides.iter().copied().zip(cells) { - painter.rect_filled(rect, CornerRadius::same(4), Color32::from_gray(32)); - let tex = match show.thumbs.iter().find(|(k, _)| *k == i).map(|(_, t)| t.clone()) { - Some(t) => Some(t), - None if made < budget => { - made += 1; - render_thumb(ctx, show, doc, i) - } - None => { - ctx.request_repaint(); - None - } - }; - if let Some(t) = tex { - painter.image(t.id(), rect, unit_uv(), Color32::WHITE); - } - let current = i == show.state.slide; - painter.rect_stroke( - rect, - CornerRadius::same(4), - Stroke::new(if current { 3.0 } else { 1.0 }, if current { Color32::WHITE } else { Color32::from_gray(70) }), - egui::StrokeKind::Inside, - ); - let hidden = doc.slides.get(i).is_some_and(|s| s.hidden); - let label = if hidden { format!("{} Hidden", i + 1) } else { format!("{}", i + 1) }; - painter.text( - pos2(rect.center().x, rect.max.y + 12.0), - Align2::CENTER_CENTER, - label, - theme::font(13.0), - if current { Color32::WHITE } else { Color32::from_gray(180) }, - ); - } -} - -fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option { - if show.thumbs.len() >= GRID_CAP { - return None; - } - let w = 320.0_f64; - let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0); - let img = deckcraft_render::render_slide( - doc, - i, - &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, - ); - let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); - show.thumbs.push((i, tex.clone())); - Some(tex) -} - -/// Render the presenter window and set `keep` false when the user ends or closes the show. -fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { - let cur = show.state.slide; - let next = show.state.peek_next(doc); - let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone()); - if let Some(n) = next - && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) - { - // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. - let next_size = crate::textures::fit_size( - (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), - crate::textures::max_side(ctx), - ); - let img = deckcraft_render::render_slide( - doc, - n, - &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, - ); - show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); - } - let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); - let no_notes = deckcraft_model::text::TextBody::default(); - let notes = doc.slides.get(cur).map_or(&no_notes, |s| &s.notes); - let clock = wall_clock(); - // The presenter clock only moves when this window is drawn again. - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - let mut actions = vec![]; - // The presenter window is dark; default buttons draw dark text on it (#221). - let button = |ui: &mut Ui, s: &str| { - ui.add(egui::Button::new(egui::RichText::new(s).size(15.0).color(Color32::WHITE)).fill(Color32::from_gray(60)).min_size(vec2(0.0, 30.0))) - }; - ctx.show_viewport_immediate( - egui::ViewportId::from_hash_of("deckcraft_presenter"), - egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]), - |ui, _| { - if !keyboard(ui.ctx(), show, doc, now, true) { - *keep = false; - } - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - let elapsed = show.timer.elapsed(now); - let paused = show.timer.paused(); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(format_elapsed(elapsed)).size(22.0).color(if paused { - Color32::from_gray(160) - } else { - Color32::WHITE - })); - ui.add_space(12.0); - ui.label(egui::RichText::new(clock.clone()).size(16.0).color(Color32::from_gray(200))); - ui.add_space(20.0); - ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if button(ui, "End Show").clicked() { - *keep = false; - } - if button(ui, "Next ▶").clicked() { - actions.push(show.state.next(doc)); - } - if button(ui, "◀ Previous").clicked() { - actions.push(show.state.prev(doc)); - } - }); - }); - ui.horizontal(|ui| { - if button(ui, if paused { "Resume" } else { "Pause" }).clicked() { - show.timer.toggle(now); - } - if button(ui, "Reset").clicked() { - show.timer.reset(now); - } - if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() { - toggle_blank(show, Color32::BLACK); - } - if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { - toggle_blank(show, Color32::WHITE); - } - if button(ui, if show.presenter_grid { "All slides on" } else { "All slides" }).clicked() { - show.presenter_grid = !show.presenter_grid; - } - if button(ui, show.zoom_label()).clicked() { - show.zoom_key(); - } - }); - ui.add_space(8.0); - let avail = ui.available_rect_before_wrap(); - if show.presenter_grid { - // The grid fills this window; the audience keeps seeing the current slide. - paint_grid(ui.painter(), ui.ctx(), show, doc, avail); - let hit = ui.interact(avail, ui.scope_id().with("presenter_grid"), Sense::click()); - if hit.clicked() - && let Some(p) = hit.interact_pointer_pos() - && let Some(n) = see_all_hit(avail, grid_indices(doc.slides.len()).len(), slide_aspect(doc), p) - && let Some(&i) = grid_indices(doc.slides.len()).get(n) - { - show.presenter_grid = false; - show.blank = None; - show.zoom = None; - show.zoom_arm = false; - actions.push(show.state.goto(doc, i)); - } - return; - } - let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, avail.max.y)); - let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max); - let cr = fit(Rect::from_min_max(left.min, pos2(left.max.x, left.min.y + left.height() * 0.8)), doc); - let cr = Rect::from_min_size(left.min, cr.size()); - if let Some(t) = &cur_tex { - ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - if let Some(z) = show.zoom { - let mark = zoom_frame(cr, z.uv()); - ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside); - } - if show.zoom_arm { - let hit = ui.interact(cr, ui.scope_id().with("presenter_zoom"), Sense::click()); - hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Zoom")); - if hit.clicked() - && let Some(p) = hit.interact_pointer_pos() - && cr.width() > 1.0 - && cr.height() > 1.0 - { - show.zoom = Some(Zoom::at((p.x - cr.min.x) / cr.width(), (p.y - cr.min.y) / cr.height())); - show.zoom_arm = false; - } - } - if let Some(c) = show.blank { - ui.painter().rect_filled(cr, CornerRadius::ZERO, c); - } - let nr = next_rect(right, doc); - ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); - match &next_tex { - Some(t) => { - ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - None => { - ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40)); - ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); - } - } - let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.0)); - let mut head = ui.new_child(egui::UiBuilder::new().max_rect(notes_head)); - head.horizontal(|ui| { - ui.label(egui::RichText::new("Notes").size(13.0).color(Color32::from_gray(170))); - if ui.button("A-").clicked() { - show.nudge_notes(-NOTES_STEP); - } - ui.label(egui::RichText::new(format!("{}", show.notes_px as i32)).size(13.0).color(Color32::from_gray(200))); - if ui.button("A+").clicked() { - show.nudge_notes(NOTES_STEP); - } - }); - let notes_rect = Rect::from_min_max(pos2(right.min.x, notes_head.max.y + 4.0), right.max); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); - egui::ScrollArea::vertical().show(&mut child, |ui| { - presenter_notes(ui, notes, show.notes_px); - }); - }); - if ui.input(|i| i.viewport().close_requested()) { - show.presenter = false; - // A grid left open in the closed window would hold auto-advance for the rest of the show. - show.presenter_grid = false; - } - }, - ); - for a in actions { - if !show.apply(a, doc, now) { - *keep = false; - } - } -} - -/// The current slide's notes in the presenter's notes pane, `px` for 12 pt text. -fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32) -> egui::Response { - let color = Color32::from_gray(230); - if notes.text().is_empty() { - ui.label(egui::RichText::new("No notes.").size(px).color(color)) - } else { - crate::notes::view(ui, notes, &crate::notes::Look { size: px, color: Some(color) }) - } -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/show/keys.rs b/crates/ui-egui/src/show/keys.rs new file mode 100644 index 000000000..71322923a --- /dev/null +++ b/crates/ui-egui/src/show/keys.rs @@ -0,0 +1,148 @@ +use super::presenter::NOTES_STEP; +use super::zoom::{plain, toggle_blank}; +use super::*; + +/// Read the current viewport's show keys before its widgets can consume them. Both the +/// audience window and Presenter View use this path, including animation steps and end-of-show. +/// `presenter` is true for Presenter View, where G opens that window's own grid (#1130). +pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, presenter: bool) -> bool { + let keys: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) + .collect() + }); + let mut keep = true; + let mut actions = vec![]; + for (k, m) in keys { + let handled = if grid_open(show, presenter) && !matches!(k, egui::Key::Escape | egui::Key::Minus) { + // See All Slides takes no other keys until a slide is chosen. + if k == egui::Key::G && plain(m) { + close_grid(show, presenter); + true + } else { + false + } + } else { + navigation_key(show, doc, k, m, presenter, &mut keep, &mut actions) || tool_key(show, k, m, now, presenter) + }; + if !handled { + continue; + } + // The same key must not also activate a focused presenter button or be handled again + // by an embedded presenter viewport. + ctx.input_mut(|i| i.consume_key(m, k)); + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + for action in actions { + if !show.apply(action, doc, now) { + keep = false; + } + } + keep +} + +/// Whether the grid that the presenter key G opens is showing in this viewport. +fn grid_open(show: &Show, presenter: bool) -> bool { + if presenter { show.presenter_grid } else { show.grid } +} + +/// Closes the grid that G opens in this viewport. +fn close_grid(show: &mut Show, presenter: bool) { + if presenter && show.presenter_grid { + show.presenter_grid = false; + } else { + show.grid = false; + } +} + +/// Keys that move through the show or end it. False when `k` isn't one of them. +fn navigation_key( + show: &mut Show, + doc: &Presentation, + k: egui::Key, + m: egui::Modifiers, + presenter: bool, + keep: &mut bool, + actions: &mut Vec, +) -> bool { + use egui::Key::*; + match k { + Escape | Minus => { + let cancelled = k == Escape && show.clear_goto(); + if !cancelled && !show.back_out(presenter) { + *keep = false; + } + } + ArrowRight | ArrowDown | Space | PageDown | N => { + if show.ended { + *keep = false; + } else { + actions.push(show.state.next(doc)); + } + } + Enter => { + if !show.goto.is_empty() { + if let Some(i) = show.take_goto() + && i < doc.slides.len() + { + actions.push(show.state.goto(doc, i)); + } + } else if show.ended { + *keep = false; + } else { + actions.push(show.state.next(doc)); + } + } + Backspace if !show.goto.is_empty() => { + show.goto.pop(); + } + ArrowLeft | ArrowUp | Backspace | PageUp => { + show.ended = false; + actions.push(show.state.prev(doc)); + } + P if m.command => show.set_pen(!show.pen), + P => { + show.ended = false; + actions.push(show.state.prev(doc)); + } + Home => { + if let Some(i) = show.state.first_index(doc) { + actions.push(show.state.goto(doc, i)); + } + } + End => { + if let Some(i) = show.state.last_index(doc) { + actions.push(show.state.goto(doc, i)); + } + } + _ => return false, + } + true +} + +/// Pen, laser, blanking, zoom, the timer, note size and typed slide numbers. False when `k` +/// isn't one of them. +fn tool_key(show: &mut Show, k: egui::Key, m: egui::Modifiers, now: f64, presenter: bool) -> bool { + use egui::Key::*; + match k { + L if m.command => show.set_laser(!show.laser), + A if m.command => show.arrow(), + B | Period => toggle_blank(show, Color32::BLACK), + W | Comma => toggle_blank(show, Color32::WHITE), + E => show.strokes.clear(), + G if plain(m) => show.open_grid(presenter), + Z if plain(m) => show.zoom_key(), + T if plain(m) => show.timer.toggle(now), + R if plain(m) => show.timer.reset(now), + OpenBracket if plain(m) => show.nudge_notes(-NOTES_STEP), + CloseBracket if plain(m) => show.nudge_notes(NOTES_STEP), + Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { + if let Some(d) = k.name().chars().next() { + show.push_slide_number(d, now); + } + } + _ => return false, + } + true +} diff --git a/crates/ui-egui/src/show/media.rs b/crates/ui-egui/src/show/media.rs new file mode 100644 index 000000000..728383961 --- /dev/null +++ b/crates/ui-egui/src/show/media.rs @@ -0,0 +1,254 @@ +use super::render::fit; +use super::*; + +/// The media-host voice for slide transition sounds (no shape has this id). +pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); + +/// Start the media of a newly shown slide: media from the previous slide stops unless it plays +/// across slides; media set to start automatically plays. Everything stops at the end. +pub(super) fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { + let cur = if show.ended { None } else { Some(show.state.slide) }; + if show.media_slide == cur { + return; + } + show.media_slide = cur; + let trans = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()); + let sound = trans.and_then(|t| t.sound); + let sound_loop = trans.is_some_and(|t| t.sound_loop); + // [Stop Previous Sound] ends a transition sound still playing. + let stop_previous = trans.is_some_and(|t| t.stop_sound); + let keep: Vec = match cur { + Some(i) => app + .media + .ids() + .into_iter() + .filter(|id| app.media.clip(*id).is_some_and(|c| crate::media::carries_over(c, doc, *id, i))) + // A transition sound plays to its end, as in PowerPoint, unless the new slide has its own. + .chain((sound.is_none() && !stop_previous).then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) + .collect(), + None => vec![], + }; + for id in app.media.ids() { + if !keep.contains(&id) { + app.media.stop(id); + } + } + if let Some(media) = sound { + // Loop Until Next Sound: it repeats until another slide's sound or Stop Previous Sound. + let clip = deckcraft_model::MediaClip { media, volume: 1.0, loop_play: sound_loop, ..Default::default() }; + app.media.play(doc_uid, doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); + } + if let Some(i) = cur { + for (id, _, clip) in crate::media::slide_media(doc, i) { + if clip.autoplay && !keep.contains(&id) { + app.media.play(doc_uid, doc, id, &clip, None, crate::media::Owner::Show); + } + } + } +} + +/// Carry out the media effects the show's steps set off: Play starts from the beginning unless +/// the clip is already playing, Pause toggles, Stop stops. +pub(super) fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { + for (id, effect) in std::mem::take(&mut show.media_cues) { + let Some(clip) = crate::media::find_clip(doc, id, Some(show.state.slide)) else { continue }; + match effect.as_str() { + "pause" => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), + "stop" => app.media.stop(id), + // Already playing (it also starts automatically): leave it running. + _ if app.media.is_playing(id) => {} + _ => app.media.play(doc_uid, doc, id, &clip, Some(0.0), crate::media::Owner::Show), + } + } +} + +/// Slide rectangle the show draws into (the reading bar keeps its strip). +pub(super) fn shown_slide_rect(ui: &Ui, show: &Show, doc: &Presentation) -> Rect { + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + fit(avail, doc) +} + +/// Playback bar for one clip: along the bottom inside a large shape, just under a small icon. +fn media_bar_rect(shape: Rect) -> Rect { + let h = 26.0f32; + let w = shape.width().clamp(160.0, 420.0); + if shape.height() >= h + 16.0 && shape.width() >= 160.0 { + Rect::from_min_size(pos2(shape.min.x + 4.0, shape.max.y - h - 4.0), vec2((shape.width() - 8.0).min(w), h)) + } else { + Rect::from_min_size(pos2(shape.center().x - w / 2.0, shape.max.y + 4.0), vec2(w, h)) + } +} + +pub(super) fn slide_media_bars(srect: Rect, doc: &Presentation, slide: usize) -> Vec<(ShapeId, deckcraft_model::MediaClip, Rect, Rect)> { + let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + crate::media::slide_media(doc, slide) + .into_iter() + .map(|(id, x, clip)| { + let r = Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)); + let bar = media_bar_rect(r); + (id, clip, r, bar) + }) + .collect() +} + +/// The clip whose bar or shape is under the pointer, when Show Media Controls is on. +pub(super) fn media_bar_target( + ctx: &egui::Context, + doc: &Presentation, + show: &Show, + srect: Rect, +) -> Option<(ShapeId, deckcraft_model::MediaClip, Rect)> { + if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() { + return None; + } + let p = ctx.input(|i| i.pointer.hover_pos())?; + let items = slide_media_bars(srect, doc, show.state.slide); + if let Some((id, clip, _, bar)) = items.iter().rev().find(|(_, _, _, bar)| bar.contains(p)) { + return Some((*id, clip.clone(), *bar)); + } + let (id, clip, _, bar) = items.iter().rev().find(|(_, _, r, _)| r.contains(p))?; + Some((*id, clip.clone(), *bar)) +} + +/// True when the pointer is on a visible playback bar, so that click must not advance the show. +pub(super) fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show, now: f64) -> bool { + if !doc.show.show_media_controls || !show.pointer_awake(now) || show.pen || show.ended || show.blank.is_some() { + return false; + } + let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false }; + let srect = shown_slide_rect(ui, show, doc); + slide_media_bars(srect, doc, show.state.slide).iter().any(|(_, _, _, bar)| bar.contains(p)) +} + +/// The media shape under the pointer on the shown slide. +pub(super) fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> Option<(ShapeId, deckcraft_model::MediaClip)> { + let p = ctx.input(|i| i.pointer.interact_pos())?; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + crate::media::slide_media(doc, show.state.slide).into_iter().rev().find_map(|(id, x, clip)| { + let r = Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)); + r.contains(p).then_some((id, clip)) + }) +} + +/// What a click on the pointer's spot acts on. +#[derive(Clone)] +pub(super) enum Pick { + /// A hyperlink in text. + Text(Hyperlink), + /// A shape with an action or a triggered animation. + Shape, +} + +/// The slide's shapes at the pointer, for [`ShowState::click`]. +#[derive(Default)] +pub(super) struct Under { + shapes: Vec, + /// Whether each of `shapes` is currently shown (an animation may have hidden it). + visible: Vec, + pub(super) pick: Option, +} + +impl Under { + fn visible(&self, id: ShapeId) -> bool { + self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) + } +} + +/// What is under the pointer on the shown slide: the slide's shapes with their animation state, the +/// topmost of them that does something when clicked, and a text hyperlink on it. Only shapes that +/// are shown can be clicked, and a shape without an action hides the ones beneath it. +pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation, now: f64) -> Option { + let p = ctx.input(|i| i.pointer.hover_pos())?; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64; + let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k); + let i = show.state.slide; + if show.links.as_ref().is_none_or(|(s, ..)| *s != i) { + let placed = deckcraft_render::place_slide(doc, i); + show.links = Some((i, placed.links, placed.shapes)); + } + show.timeline(doc); + let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; + let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1; + let hits: Vec = shapes + .iter() + .map(|s| HitShape { + id: s.id, + parent: s.parent, + is_group: s.is_group, + link: s.link.as_ref().map(|l| l.action.clone()), + under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1), + }) + .collect(); + let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps()); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let visible: Vec = shapes + .iter() + .map(|s| { + let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); + a.visible && a.opacity > 0.0 + }) + .collect(); + let mut under = Under { shapes: hits, visible, pick: None }; + let shown = |id: ShapeId| under.visible(id); + // A hyperlink in text applies when its shape is the one on top. + let top = show.state.top(doc, tl, &under.shapes, &shown); + let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| { + let mut at = top; + (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { + Some(h) if h.id == l.shape => true, + Some(h) => { + at = h.parent; + false + } + None => false, + }) + }); + under.pick = match text { + Some(l) => Some(Pick::Text(l.link.clone())), + None => show.state.target(doc, tl, &under.shapes, &shown).map(|_| Pick::Shape), + }; + Some(under) +} + +impl Show { + /// Advance the clock of the running triggered animations. + pub(super) fn tick_triggers(&mut self, doc: &Presentation, now: f64) { + self.timeline(doc); + if let Some((_, tl)) = &self.timeline { + self.state.tick(tl, now); + } + } + + /// A click on the slide: the shape under the pointer acts, or the background advances. + pub(super) fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { + self.timeline(doc); + let last = self.last_viewed; + match &self.timeline { + Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last), + None => Click::None, + } + } +} + +/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. +pub(super) fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { + Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)) +} + +/// The animated GIF whose play/pause button is under the pointer on the shown slide. +pub(super) fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, doc: &Presentation, app: &mut SlideApp) -> Option { + let p = ctx.input(|i| i.pointer.interact_pos())?; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); + gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id) +} diff --git a/crates/ui-egui/src/show/presenter.rs b/crates/ui-egui/src/show/presenter.rs new file mode 100644 index 000000000..17f78fa5e --- /dev/null +++ b/crates/ui-egui/src/show/presenter.rs @@ -0,0 +1,258 @@ +use super::keys::keyboard; +use super::render::{fit, next_rect}; +use super::zoom::{Zoom, grid_indices, paint_grid, see_all_hit, slide_aspect, toggle_blank, zoom_frame}; +use super::*; + +pub(super) const NOTES_DEFAULT: f32 = 18.0; + +pub(super) const NOTES_MIN: f32 = 12.0; + +pub(super) const NOTES_MAX: f32 = 48.0; + +pub(super) const NOTES_STEP: f32 = 4.0; + +/// Presenter-view elapsed time. Pausing freezes the number; the show keeps playing. +#[derive(Clone, Copy)] +pub(super) struct PresenterTimer { + origin: f64, + frozen: f64, + paused: bool, +} + +impl PresenterTimer { + pub(super) fn start(now: f64) -> Self { + PresenterTimer { origin: finite_time(now), frozen: 0.0, paused: false } + } + + pub(super) fn elapsed(self, now: f64) -> f64 { + if self.paused { + return finite_secs(self.frozen); + } + let dt = now - self.origin; + finite_secs(self.frozen + if dt.is_finite() && dt > 0.0 { dt } else { 0.0 }) + } + + pub(super) fn paused(self) -> bool { + self.paused + } + + pub(super) fn toggle(&mut self, now: f64) { + if self.paused { + self.origin = finite_time(now); + self.paused = false; + } else { + self.frozen = self.elapsed(now); + self.paused = true; + } + } + + /// Zero the elapsed time. A paused timer stays paused at zero. + pub(super) fn reset(&mut self, now: f64) { + self.frozen = 0.0; + self.origin = finite_time(now); + } +} + +fn finite_time(now: f64) -> f64 { + if now.is_finite() { now } else { 0.0 } +} + +fn finite_secs(n: f64) -> f64 { + if n.is_finite() && n >= 0.0 { n } else { 0.0 } +} + +pub(super) fn format_elapsed(secs: f64) -> String { + let s = finite_secs(secs) as u64; + let s = s.min(99 * 3600 + 59 * 60 + 59); + format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) +} + +/// Local wall clock for presenter view. `--:--` where there is no clock (wasm). +pub(super) fn wall_clock() -> String { + match deckcraft_text::datetime::DateTime::now_local() { + Some(t) if t.hour < 24 && t.minute < 60 => format!("{:02}:{:02}", t.hour, t.minute), + _ => "--:--".to_string(), + } +} + +/// Render the presenter window and set `keep` false when the user ends or closes the show. +pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { + let cur = show.state.slide; + let next = show.state.peek_next(doc); + let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone()); + if let Some(n) = next + && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) + { + // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. + let next_size = crate::textures::fit_size( + (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ctx), + ); + let img = deckcraft_render::render_slide( + doc, + n, + &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, + ); + show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); + } + let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); + let no_notes = deckcraft_model::text::TextBody::default(); + let notes = doc.slides.get(cur).map_or(&no_notes, |s| &s.notes); + let clock = wall_clock(); + // The presenter clock only moves when this window is drawn again. + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + let mut actions = vec![]; + // The presenter window is dark; default buttons draw dark text on it (#221). + let button = |ui: &mut Ui, s: &str| { + ui.add(egui::Button::new(egui::RichText::new(s).size(15.0).color(Color32::WHITE)).fill(Color32::from_gray(60)).min_size(vec2(0.0, 30.0))) + }; + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("deckcraft_presenter"), + egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]), + |ui, _| { + if !keyboard(ui.ctx(), show, doc, now, true) { + *keep = false; + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + let elapsed = show.timer.elapsed(now); + let paused = show.timer.paused(); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(format_elapsed(elapsed)).size(22.0).color(if paused { + Color32::from_gray(160) + } else { + Color32::WHITE + })); + ui.add_space(12.0); + ui.label(egui::RichText::new(clock.clone()).size(16.0).color(Color32::from_gray(200))); + ui.add_space(20.0); + ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if button(ui, "End Show").clicked() { + *keep = false; + } + if button(ui, "Next ▶").clicked() { + actions.push(show.state.next(doc)); + } + if button(ui, "◀ Previous").clicked() { + actions.push(show.state.prev(doc)); + } + }); + }); + ui.horizontal(|ui| { + if button(ui, if paused { "Resume" } else { "Pause" }).clicked() { + show.timer.toggle(now); + } + if button(ui, "Reset").clicked() { + show.timer.reset(now); + } + if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() { + toggle_blank(show, Color32::BLACK); + } + if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { + toggle_blank(show, Color32::WHITE); + } + if button(ui, if show.presenter_grid { "All slides on" } else { "All slides" }).clicked() { + show.presenter_grid = !show.presenter_grid; + } + if button(ui, show.zoom_label()).clicked() { + show.zoom_key(); + } + }); + ui.add_space(8.0); + let avail = ui.available_rect_before_wrap(); + if show.presenter_grid { + // The grid fills this window; the audience keeps seeing the current slide. + paint_grid(ui.painter(), ui.ctx(), show, doc, avail); + let hit = ui.interact(avail, ui.scope_id().with("presenter_grid"), Sense::click()); + if hit.clicked() + && let Some(p) = hit.interact_pointer_pos() + && let Some(n) = see_all_hit(avail, grid_indices(doc.slides.len()).len(), slide_aspect(doc), p) + && let Some(&i) = grid_indices(doc.slides.len()).get(n) + { + show.presenter_grid = false; + show.blank = None; + show.zoom = None; + show.zoom_arm = false; + actions.push(show.state.goto(doc, i)); + } + return; + } + let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, avail.max.y)); + let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max); + let cr = fit(Rect::from_min_max(left.min, pos2(left.max.x, left.min.y + left.height() * 0.8)), doc); + let cr = Rect::from_min_size(left.min, cr.size()); + if let Some(t) = &cur_tex { + ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + if let Some(z) = show.zoom { + let mark = zoom_frame(cr, z.uv()); + ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside); + } + if show.zoom_arm { + let hit = ui.interact(cr, ui.scope_id().with("presenter_zoom"), Sense::click()); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Zoom")); + if hit.clicked() + && let Some(p) = hit.interact_pointer_pos() + && cr.width() > 1.0 + && cr.height() > 1.0 + { + show.zoom = Some(Zoom::at((p.x - cr.min.x) / cr.width(), (p.y - cr.min.y) / cr.height())); + show.zoom_arm = false; + } + } + if let Some(c) = show.blank { + ui.painter().rect_filled(cr, CornerRadius::ZERO, c); + } + let nr = next_rect(right, doc); + ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); + match &next_tex { + Some(t) => { + ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40)); + ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); + } + } + let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.0)); + let mut head = ui.new_child(egui::UiBuilder::new().max_rect(notes_head)); + head.horizontal(|ui| { + ui.label(egui::RichText::new("Notes").size(13.0).color(Color32::from_gray(170))); + if ui.button("A-").clicked() { + show.nudge_notes(-NOTES_STEP); + } + ui.label(egui::RichText::new(format!("{}", show.notes_px as i32)).size(13.0).color(Color32::from_gray(200))); + if ui.button("A+").clicked() { + show.nudge_notes(NOTES_STEP); + } + }); + let notes_rect = Rect::from_min_max(pos2(right.min.x, notes_head.max.y + 4.0), right.max); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); + egui::ScrollArea::vertical().show(&mut child, |ui| { + presenter_notes(ui, notes, show.notes_px); + }); + }); + if ui.input(|i| i.viewport().close_requested()) { + show.presenter = false; + // A grid left open in the closed window would hold auto-advance for the rest of the show. + show.presenter_grid = false; + } + }, + ); + for a in actions { + if !show.apply(a, doc, now) { + *keep = false; + } + } +} + +/// The current slide's notes in the presenter's notes pane, `px` for 12 pt text. +pub(super) fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32) -> egui::Response { + let color = Color32::from_gray(230); + if notes.text().is_empty() { + ui.label(egui::RichText::new("No notes.").size(px).color(color)) + } else { + crate::notes::view(ui, notes, &crate::notes::Look { size: px, color: Some(color) }) + } +} diff --git a/crates/ui-egui/src/show/render.rs b/crates/ui-egui/src/show/render.rs new file mode 100644 index 000000000..bf2780645 --- /dev/null +++ b/crates/ui-egui/src/show/render.rs @@ -0,0 +1,210 @@ +use super::zoom::GRID_CAP; +use super::*; + +pub(super) fn render_threads() -> u16 { + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } +} + +pub(super) fn render_backdrop(doc: &Presentation, key: BackKey, threads: u16) -> deckcraft_render::Image { + let opts = RenderOpts { scale: key.size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(key.size), threads, ..Default::default() }; + deckcraft_render::render_slide_part(doc, key.slide, deckcraft_render::SlidePart::Backdrop, &opts) +} + +/// `top` laid over `back` (both premultiplied RGBA of the same size; `top` alone otherwise). +fn over(back: &deckcraft_render::Image, top: &deckcraft_render::Image) -> egui::ColorImage { + if back.width != top.width || back.height != top.height || back.pixels.len() != top.pixels.len() { + return crate::textures::to_color_image(top, false); + } + let px = back + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(top.pixels.as_chunks::<4>().0) + .map(|(b, t)| { + let k = 255 - t[3] as u32; + let mix = |i: usize| (t[i] as u32 + (b[i] as u32 * k + 127) / 255).min(255) as u8; + Color32::from_rgba_premultiplied(mix(0), mix(1), mix(2), mix(3)) + }) + .collect(); + egui::ColorImage::new([top.width as usize, top.height as usize], px) +} + +#[allow(clippy::too_many_arguments)] +/// Slide `idx` at `step`, `t` seconds into it, `size` pixels, with `hidden` media hidden. With a +/// `backdrop` (of this slide, at this size), only the slide's shapes are rendered, over it. +pub(super) fn render_frame( + doc: &Presentation, + idx: usize, + tl: Timeline, + step: usize, + t: f64, + size: (u32, u32), + hidden: Vec, + times: &[(ShapeId, f64)], + threads: u16, + plays: &[TriggerPlay], + stamps: &[StepStamp], + backdrop: Option<&deckcraft_render::Image>, +) -> egui::ColorImage { + let f = move |id: ShapeId| { + let mut st = state_for(&tl, id, step, t, plays, stamps); + if hidden.contains(&id) { + st.visible = false; + } + st + }; + let opts = RenderOpts { + scale: size.0 as f64 / doc.slide_size.width.max(1.0), + size: Some(size), + state: Some(&f), + gif_times: times, + threads, + ..Default::default() + }; + match backdrop { + Some(back) => over(back, &deckcraft_render::render_slide_part(doc, idx, deckcraft_render::SlidePart::Content, &opts)), + None => crate::textures::to_color_image(&deckcraft_render::render_slide(doc, idx, &opts), false), + } +} + +/// Render only the slide's own shapes except `skip`, over transparency: what is stacked above a +/// playing video, to be drawn over the video frame. +#[allow(clippy::too_many_arguments)] +pub(super) fn render_overlay( + doc: &Presentation, + idx: usize, + tl: Timeline, + step: usize, + t: f64, + size: (u32, u32), + hidden: Vec, + skip: &[ShapeId], + times: &[(ShapeId, f64)], + threads: u16, + plays: &[TriggerPlay], + stamps: &[StepStamp], +) -> egui::ColorImage { + let f = move |id: ShapeId| { + let mut st = state_for(&tl, id, step, t, plays, stamps); + if hidden.contains(&id) { + st.visible = false; + } + st + }; + let opts = RenderOpts { + scale: size.0 as f64 / doc.slide_size.width.max(1.0), + size: Some(size), + state: Some(&f), + skip, + gif_times: times, + threads, + ..Default::default() + }; + let img = deckcraft_render::render_slide_part(doc, idx, deckcraft_render::SlidePart::Content, &opts); + crate::textures::to_color_image(&img, false) +} + +/// The shapes of slide `idx` to leave out when drawing what is stacked above the lowest of the +/// playing `videos` (that video, everything below it and the other videos). Empty when nothing +/// is above a video: the video frame is then painted last. +pub(super) fn below_playing_video(doc: &Presentation, idx: usize, videos: &[ShapeId]) -> Vec { + let Some(slide) = doc.slides.get(idx) else { return vec![] }; + let Some(first) = slide.shapes.iter().position(|s| videos.contains(&s.id)) else { return vec![] }; + if slide.shapes.iter().skip(first + 1).all(|s| videos.contains(&s.id)) { + return vec![]; + } + slide.shapes.iter().enumerate().filter(|(i, s)| *i <= first || videos.contains(&s.id)).map(|(_, s)| s.id).collect() +} + +/// Media on slide `idx` hidden until it plays, as it is when the slide is entered. +fn hidden_on_entry(doc: &Presentation, idx: usize) -> Vec { + crate::media::slide_media(doc, idx).into_iter().filter(|(_, _, clip)| clip.hide_while_not_playing).map(|m| m.0).collect() +} + +/// Start rendering slide `next`'s first frame on another thread. `None` on the web (no threads). +pub(super) fn start_prefetch(doc: &std::sync::Arc, next: usize, size: (u32, u32)) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let hidden = hidden_on_entry(doc, next); + let key = Key { slide: next, step: 0, size, doc: std::sync::Arc::as_ptr(doc) as usize, hidden: hidden.clone(), gif: 0, plays: vec![] }; + let (tx, rx) = std::sync::mpsc::channel(); + let doc = doc.clone(); + // Leave a core for the UI thread. + let threads = render_threads().saturating_sub(1).max(1); + let spawned = std::thread::Builder::new().name("show-prefetch".into()).spawn(move || { + let tl = ShowState::timeline(&doc, next); + let _ = tx.send(render_frame(&doc, next, tl, 0, 0.0, size, hidden, &[], threads, &[], &[], None)); + }); + spawned.ok().map(|_| Prefetch { key, rx }) +} + +pub(super) fn fit(avail: Rect, doc: &Presentation) -> Rect { + // Loaded sizes are repaired, but a zero side here would make every rectangle NaN. + let side = |v: f64| if v.is_finite() { (v as f32).max(1.0) } else { 1.0 }; + let (sw, sh) = (side(doc.slide_size.width), side(doc.slide_size.height)); + let s = (avail.width() / sw).min(avail.height() / sh).max(0.0); + Rect::from_center_size(avail.center(), vec2(sw * s, sh * s)) +} + +/// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective +/// cards subdivided so their pictures stay straight) with per-vertex opacity. +/// Where presenter view's "Next" preview goes: the column's width below its label, kept inside +/// the top 60% of the column. A portrait slide used to get the full width and a height many +/// times the window (a white slab over the notes and past the window edge). +pub(super) fn next_rect(right: Rect, doc: &Presentation) -> Rect { + let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6)); + Rect::from_min_size(area.min, fit(area, doc).size()) +} + +pub(super) fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { + let mut m = match tex { + Some(id) => Mesh::with_texture(id), + None => Mesh::default(), + }; + let (verts, tris) = deckcraft_anim::layer_mesh(l); + for v in &verts { + let a = if v.a.is_finite() { v.a.clamp(0.0, 1.0) } else { 0.0 }; + let col = if tex.is_some() { Color32::from_white_alpha((a * 255.0).round() as u8) } else { color.gamma_multiply(a as f32) }; + let p = pos2(rect.min.x + v.pos[0] as f32 * rect.width(), rect.min.y + v.pos[1] as f32 * rect.height()); + m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(v.uv[0] as f32, v.uv[1] as f32), color: col }); + } + for t in tris { + m.add_triangle(t[0], t[1], t[2]); + } + m +} + +/// Render the backgrounds and contents of a transition's two slides into textures, once. +pub(super) fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, to: usize, size: (u32, u32)) -> [Option; 4] { + use deckcraft_render::SlidePart; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + let tex = |i: usize, part: SlidePart, name: &str| { + let img = deckcraft_render::render_slide_part(doc, i, part, &opts); + crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)) + }; + [ + from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), + from.map(|i| tex(i, SlidePart::Content, "trans-old-content")), + Some(tex(to, SlidePart::Backdrop, "trans-new-back")), + Some(tex(to, SlidePart::Content, "trans-new-content")), + ] +} + +pub(super) fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option { + if show.thumbs.len() >= GRID_CAP { + return None; + } + let w = 320.0_f64; + let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0); + let img = deckcraft_render::render_slide( + doc, + i, + &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, + ); + let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); + show.thumbs.push((i, tex.clone())); + Some(tex) +} diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs new file mode 100644 index 000000000..e769d66f6 --- /dev/null +++ b/crates/ui-egui/src/show/ui.rs @@ -0,0 +1,612 @@ +use super::keys::keyboard; +use super::media::{ + Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media, +}; +use super::presenter::{PresenterTimer, presenter_window}; +use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch}; +use super::zoom::{ + Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv, + wheel_zoom_lines, +}; +use super::*; + +/// Draw and advance the active show, handling hidden frames, timing, input, transitions, and +/// presenter/reading presentation. +pub fn ui(app: &mut SlideApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { + app.show = None; + return; + }; + let now = ctx.input(|i| i.time); + let Some(mut show) = app.show.take() else { return }; + if !viewport_visible(&ctx) { + // A forced hidden paint (for example a pending screenshot) draws the current frame; + // the show clocks are advanced by logic, not by this draw, so it must not advance them a + // second time. Keep drawing the last frame rather than returning a blank. + if show.started { + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + if let Some((_, tex)) = &show.tex { + painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); + } + } + app.show = Some(show); + return; + } + show.observe_visible_frame(now, web_time::Instant::now()); + if !show.started { + show.started = true; + show.slide_start = now; + show.step_start = now; + // The first step is settled at the show's own start: every clock origin is a real frame + // time, so hiding and resuming shifts all of them by the same interval. + show.step_end = now; + show.show_start = now; + show.timer = PresenterTimer::start(now); + show.last_move = now; + if show_fills_screen(show.reading, &doc.show.show_type) { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); + } + show.after_enter(&doc, now); + } + let mut keep = keyboard(&ctx, &mut show, &doc, now, false); + let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); + if moved { + show.last_move = now; + } + let mut actions: Vec = vec![]; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let pointer = ctx.input(|i| i.pointer.interact_pos()); + show.expire_goto(now); + // The clock of the triggered animations runs before anything reacts to the pointer. + show.tick_triggers(&doc, now); + let on_slide = !show.grid + && !show.pen + && !show.laser + && !show.ended + && show.blank.is_none() + && show.zoom.is_none() + && !show.zoom_arm + && !show.reading_bar_hit(&ctx) + && !pointer_on_media_bar(&ctx, ui, &doc, &show, now); + // The button is drawn only while the pointer is awake; a hidden button takes no click. + let gif_click = if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) { + gif_hit(&ctx, ui, &show, doc_uid, &doc, app) + } else { + None + }; + let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; + let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; + let pointing = under.as_ref().is_some_and(|u| u.pick.is_some()); + if let Some(id) = gif_click { + let _ = app.run("media.gifPlay", json!({"id": id})); + } else if let Some((id, clip)) = media_click { + app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); + } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) { + let last = show.last_viewed; + match show.state.follow_link(&doc, &l, last) { + LinkJump::Show(a) => actions.push(a), + LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + LinkJump::Exit => keep = false, + LinkJump::None => {} + } + } else if show.grid && clicked { + let aspect = slide_aspect(&doc); + if let Some(p) = pointer + && let Some(n) = see_all_hit(full, grid_indices(doc.slides.len()).len(), aspect, p) + && let Some(&i) = grid_indices(doc.slides.len()).get(n) + { + show.grid = false; + show.blank = None; + show.zoom = None; + show.zoom_arm = false; + actions.push(show.state.goto(&doc, i)); + } + } else if clicked && show.zoom_arm && !show.reading_bar_hit(&ctx) { + if let Some(p) = pointer + && let Some((nx, ny)) = norm_on_slide(srect, unit_uv(), p) + { + show.zoom = Some(Zoom::at(nx, ny)); + show.zoom_arm = false; + } + } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { + show.dismiss_zoom(); + } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) { + if show.ended { + keep = false; + } else if show.blank.is_some() { + show.blank = None; + } else { + // A shape with an action or a triggered animation, or the background. + let none = Under::default(); + match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) { + Click::Show(a) => actions.push(a), + Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Click::Exit => keep = false, + Click::Animated | Click::None => {} + } + } + } + if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm { + actions.push(show.state.prev(&doc)); + } + // Ctrl+wheel (Cmd+wheel on a Mac) zooms around the pointer, as in PowerPoint (#1286). egui + // keeps it out of `smooth_scroll_delta`, so it does not also turn the page below. + let line_points = ctx.options(|o| o.input_options.line_scroll_speed); + if !show.grid + && !show.ended + && show.blank.is_none() + && let Some(p) = pointer + && let Some(lines) = ctx.input(|i| wheel_zoom_lines(&i.events, line_points)) + { + show.wheel_zoom(srect, p, lines); + } + // A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick + // there stays on the slide. Pinch zoom is not a show gesture. + if !show.pen + && let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta)) + && now - show.last_swipe > 0.35 + { + show.last_swipe = now; + if next { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } else { + show.ended = false; + actions.push(show.state.prev(&doc)); + } + } + // Apply input first: a step started by this click must restart the clock before it is read. + for a in actions.drain(..) { + if !show.apply(a, &doc, now) { + keep = false; + } + } + // Step clock, shared with the hidden logic tick. + if !show.queue_clock_actions(&doc, now, &mut actions) { + keep = false; + } + for a in actions { + if !show.apply(a, &doc, now) { + keep = false; + } + } + if show.state.playing { + let step = show.state.step; + show.cue_media(&doc, step, now - show.step_start); + } + show_media(app, &mut show, doc_uid, &doc); + run_media_cues(app, &mut show, doc_uid, &doc); + // Draw. + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + let ppp = ctx.pixels_per_point(); + // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit + // (drawn stretched to the screen). + let size = crate::textures::fit_size( + ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), + crate::textures::max_side(&ctx), + ); + if show.ended { + painter.text( + pos2(full.center().x, full.min.y + 40.0), + Align2::CENTER_CENTER, + "End of slide show, click to exit.", + theme::font(18.0), + Color32::from_gray(200), + ); + } else { + let idx = show.state.slide; + let step = show.state.step; + // A main step or a triggered animation is running: the picture changes every frame. + let animating = show.state.playing || show.state.trigger_playing(); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let plays = show.state.trigger_plays(); + let stamps = show.state.trigger_stamps(); + let media = crate::media::slide_media(&doc, idx); + let hidden: Vec = media + .iter() + .filter(|(id, _, clip)| { + clip.hide_while_not_playing && !app.media.status(*id).is_some_and(|s| s.state != deckcraft_media::PlayState::Ended) + }) + .map(|m| m.0) + .collect(); + let gifs = app.media.gifs(doc_uid, &doc, idx, now, &app.session.gif_paused); + let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); + crate::media::gif_repaint(&ctx, &gifs); + let gif = crate::media::gif_key(&gifs); + let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); + let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; + let back_key = BackKey { slide: idx, size, doc: key.doc }; + let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { + show.tex.as_ref().map(|(_, t)| t.clone()) + } else { + // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). + let ready = match show.prefetch.take() { + Some(p) if p.key == key && !animating => p.rx.recv().ok(), + _ => None, + }; + let ci = match ready { + Some(ci) => ci, + None => { + let tl = show.timeline(&doc).clone(); + // Animating: the backdrop doesn't change, only the shapes over it are drawn. + let back = animating.then(|| show.backdrop(&doc, back_key)); + render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps, back.as_deref()) + } + }; + let tex = match show.tex.take() { + Some((_, mut h)) => { + crate::textures::replace(&ctx, &mut h, ci); + h + } + None => crate::textures::upload(&ctx, "show", ci), + }; + show.tex = Some((key.clone(), tex.clone())); + Some(tex) + }; + // While this slide rests, render the next one's first frame (not one with GIFs, whose + // frame depends on when it is entered). + // A slide with animations gets its backdrop ready while it rests, so they start smoothly. + if !animating && !show.timeline(&doc).is_empty() { + show.prefetch_backdrop(&doc, back_key); + } + if !animating && show.trans.is_none() && !show.preview_only { + let next = show.state.peek_next(&doc); + let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); + if let Some(n) = wanted + && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) + { + show.prefetch = start_prefetch(&doc, n, size); + } + } + let mut drew = false; + let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); + let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None }; + let morph = match (&show.trans, tl) { + (Some(tr), Some(tl)) => { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + let hidden_b = |id: ShapeId| { + let st = state_for(&tl, id, 0, 0.0, &[], &[]); + !st.visible || st.opacity <= 0.0 || hidden.contains(&id) + }; + tr.from + .and_then(|i| doc.slides.get(i).map(|a| (i, a))) + .zip(doc.slides.get(idx)) + .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b))) + } + _ => None, + }; + if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) { + let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; + let threads = + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = + RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() }; + let text: Vec = m + .text + .iter() + .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }) + .collect(); + let paths: Vec = m + .paths + .iter() + .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) + .collect(); + let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts); + let ci = crate::textures::to_color_image(&img, false); + let frame = match tr.morph.take() { + Some(mut h) => { + crate::textures::replace(&ctx, &mut h, ci); + h + } + None => crate::textures::upload(&ctx, "morph", ci), + }; + painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + tr.morph = Some(frame); + drew = true; + ctx.request_repaint(); + } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + if p < 1.0 { + // Slides split into background and content are rendered once per transition; + // every frame only composites cached textures. + if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { + tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size)); + } + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); + let parts = [part(0), part(1), part(2), part(3)]; + for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { + let old_or_black = |t: Option<&TextureHandle>| match t { + Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), + None => quad_mesh(None, srect, &l, Color32::BLACK), + }; + let mesh = match l.source { + Source::Old => old_or_black(tr.old.as_ref()), + Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), + Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), + Source::White => quad_mesh(None, srect, &l, Color32::WHITE), + Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), + Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), + Source::OldContent => match &parts[1] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + Source::NewContent => match &parts[3] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + }; + painter.add(egui::Shape::mesh(mesh)); + } + drew = true; + ctx.request_repaint(); + } + } + if !drew { + show.trans = None; + if let Some(tex) = &tex { + painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); + } + // Playing video: the current frame in its box, or full screen. + let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); + let uv = show.zoom_uv(); + let mut framed: Vec = vec![]; + let mut full_screen = false; + for (id, x, clip) in &media { + if !clip.video || hidden.contains(id) { + continue; + } + let r = if clip.full_screen && app.media.is_playing(*id) { + full_screen = true; + full + } else { + slide_box(srect, uv, sw, sh, x.x as f32, x.y as f32, x.w as f32, x.h as f32) + }; + if crate::media::paint_frame(&painter, &app.media, *id, r, clip.crop) { + framed.push(*id); + } + } + // Shapes stacked above a playing video show over it, as in PowerPoint (#1005); a video + // playing full screen covers the whole slide. + let skip = if full_screen { vec![] } else { below_playing_video(&doc, idx, &framed) }; + if skip.is_empty() { + show.overlay = None; + } else { + let fresh = !animating && show.overlay.as_ref().is_some_and(|(k, s, _)| *k == key && *s == skip); + if !fresh { + let tl = show.timeline(&doc).clone(); + let ci = render_overlay(&doc, idx, tl, step, t, size, hidden.clone(), &skip, ×, render_threads(), &plays, &stamps); + let handle = match show.overlay.take() { + Some((_, _, mut h)) => { + crate::textures::replace(&ctx, &mut h, ci); + h + } + None => crate::textures::upload(&ctx, "show-overlay", ci), + }; + show.overlay = Some((key.clone(), skip, handle)); + } + if let Some((_, _, h)) = &show.overlay { + painter.image(h.id(), srect, uv, Color32::WHITE); + } + } + // Play/pause on the animated GIF under the pointer, and the audio/video bar. + // Slide Show ▸ Show Media Controls hides both (the click still plays a clip). + // Not while zoomed: the controls sit where the whole slide would be. + if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { + let k = srect.width() / sw.max(1.0); + if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { + for g in &gifs { + let r = on_screen(srect, k, &g.rect); + if r.contains(p) { + let b = crate::media::gif_button_rect(r); + crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p)); + } + } + } + if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + let action = { + let status = app.media.status(id); + crate::media::control_bar(ui, bar, status.as_ref(), &clip) + }; + let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); + match action { + Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, &doc, id, &clip, t, crate::media::Owner::Show), + Some(crate::media::BarAction::Nudge(dt)) => { + app.media.seek(doc_uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show) + } + None => {} + } + } + } + } + if animating { + ctx.request_repaint(); + } + } + if let Some(c) = show.blank { + painter.rect_filled(full, CornerRadius::ZERO, c); + } + if !show.goto.is_empty() { + let label = format!("Go to slide {}", show.goto); + let w = (28.0 + label.len() as f32 * 10.0).clamp(160.0, full.width() - 32.0); + let r = Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 96.0), vec2(w, 36.0)); + painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(190)); + painter.text(r.center(), Align2::CENTER_CENTER, label, theme::font(18.0), Color32::WHITE); + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + // Pen. + let resp = ui.interact(full, ui.scope_id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() }); + if show.pen { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Pen")); + } + if show.pen { + if resp.drag_started() { + show.strokes.push(vec![]); + } + let uv = show.zoom_uv(); + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + && let Some((nx, ny)) = norm_on_slide(srect, uv, p) + && let Some(s) = show.strokes.last_mut() + { + s.push(pos2(nx, ny)); + } + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } else if show.laser { + ctx.set_cursor_icon(egui::CursorIcon::None); + } else if show.zoom_arm || show.grid { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } else if !show.pointer_awake(now) && !show.reading { + // Idle first: a still pointer hides also over a clickable shape, such as a playing clip. + ctx.set_cursor_icon(egui::CursorIcon::None); + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } else if pointing { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } + let pen_col = theme::to_color32(doc.show.pen_color); + let uv = show.zoom_uv(); + for s in &show.strokes { + let pts: Vec = s.iter().map(|q| slide_to_screen(srect, uv, q.x, q.y)).collect(); + painter.add(egui::Shape::line(pts, Stroke::new(3.0, pen_col))); + } + if show.laser + && !show.ended + && let Some(p) = ctx.input(|i| i.pointer.hover_pos()) + { + let c = theme::to_color32(doc.show.laser_color); + painter.circle_filled(p, 16.0, Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), 70)); + painter.circle_filled(p, 5.5, c); + } + if show.zoom_arm && !show.grid { + painter.text( + pos2(full.center().x, full.min.y + 36.0), + Align2::CENTER_CENTER, + "Click the slide to zoom in. Esc cancels.", + theme::font(16.0), + Color32::WHITE, + ); + } + // Reading view bar / on-screen controls. + if show.reading { + let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max); + painter.rect_filled(bar, CornerRadius::ZERO, Color32::from_gray(32)); + painter.text( + pos2(bar.min.x + 14.0, bar.center().y), + Align2::LEFT_CENTER, + format!("Slide {} of {}", show.state.slide + 1, doc.slides.len()), + theme::font(12.0), + Color32::from_gray(200), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(bar.max.x - 260.0, bar.min.y), bar.max)) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + if child.button("Close").clicked() { + keep = false; + } + if child.button("▶").clicked() { + let a = show.state.next(&doc); + if !show.apply(a, &doc, now) { + keep = false; + } + } + if child.button("◀").clicked() { + let a = show.state.prev(&doc); + show.apply(a, &doc, now); + } + } else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) { + let bar = tool_bar_rect(full); + painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140)); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let b = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).color(Color32::WHITE).size(15.0)).frame(false)); + if b(&mut child, "◀").clicked() { + let a = show.state.prev(&doc); + show.apply(a, &doc, now); + } + if b(&mut child, "▶").clicked() { + let a = show.state.next(&doc); + if !show.apply(a, &doc, now) { + keep = false; + } + } + if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() { + show.set_pen(!show.pen); + } + if b(&mut child, if show.laser { "◉ on" } else { "◉" }).clicked() { + show.set_laser(!show.laser); + } + if b(&mut child, "Black").clicked() { + toggle_blank(&mut show, Color32::BLACK); + } + if b(&mut child, "White").clicked() { + toggle_blank(&mut show, Color32::WHITE); + } + if b(&mut child, "All").clicked() { + show.grid = !show.grid; + } + if b(&mut child, show.zoom_label()).clicked() { + show.zoom_key(); + } + if b(&mut child, "End").clicked() { + keep = false; + } + ctx.request_repaint_after(std::time::Duration::from_millis(300)); + } + if show.grid && !show.ended { + paint_grid(&painter, &ctx, &mut show, &doc, full); + } + if show.rehearse { + let t = now - show.slide_start; + let total = now - show.show_start; + let r = Rect::from_min_size(pos2(full.min.x + 12.0, full.min.y + 12.0), vec2(220.0, 30.0)); + painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(160)); + painter.text( + r.center(), + Align2::CENTER_CENTER, + format!("Slide {:02}:{:04.1} Total {:02}:{:02}", (t / 60.0) as u32, t % 60.0, (total / 60.0) as u32, (total % 60.0) as u32), + theme::font(13.0), + Color32::WHITE, + ); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + // Presenter view in a second window. + if keep && show.presenter && !show.reading { + presenter_window(&ctx, &mut show, &doc, now, &mut keep); + } + if keep { + app.show = Some(show); + } else { + app.media.stop_owned(crate::media::Owner::Show); + if !show.reading { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); + } + if show.rehearse { + if let Some((_, tl)) = &show.timeline { + let _ = tl; + } + show.timings.push((show.state.slide, now - show.slide_start)); + for (i, secs) in show.timings.clone() { + let _ = app.run("transition.timing", json!({"index": i, "after": (secs * 1000.0).round() as u64})); + } + app.set_status("Slide timings recorded."); + } + let last = show.state.slide; + let _ = app.run("slide.go", json!({"index": last})); + } +} diff --git a/crates/ui-egui/src/show/zoom.rs b/crates/ui-egui/src/show/zoom.rs new file mode 100644 index 000000000..97d306a40 --- /dev/null +++ b/crates/ui-egui/src/show/zoom.rs @@ -0,0 +1,403 @@ +use super::presenter::{NOTES_MAX, NOTES_MIN}; +use super::render::render_thumb; +use super::*; + +impl Show { + pub(super) fn zoom_uv(&self) -> Rect { + self.zoom.map(Zoom::uv).unwrap_or_else(unit_uv) + } + + pub(super) fn zoom_label(&self) -> &'static str { + if self.zoom.is_some() { + "Zoom on" + } else if self.zoom_arm { + "Zoom…" + } else { + "Zoom" + } + } + + /// G opens See All Slides in the window it was pressed in: Presenter View's grid stays off the audience screen (#1130). + pub(super) fn open_grid(&mut self, presenter: bool) { + if presenter { + self.presenter_grid = true; + } else { + self.grid = true; + } + } + + /// Z, or the Zoom button: arm a click-to-zoom, or leave a zoom that is already on. + pub(super) fn zoom_key(&mut self) { + if self.zoom.is_some() || self.zoom_arm { + self.dismiss_zoom(); + } else { + self.zoom_arm = true; + self.pen = false; + } + } + + /// Ctrl/Cmd+wheel: `lines` notches in (positive) or out around the slide point under `p`. + /// Back to the whole slide leaves the zoom. A pointer off the slide does nothing. + pub(super) fn wheel_zoom(&mut self, srect: Rect, p: Pos2, lines: f32) { + let uv = self.zoom_uv(); + let Some((nx, ny)) = norm_on_slide(srect, uv, p) else { return }; + let from = self.zoom.map_or(1.0, |z| z.scale); + let scale = (from * 2f32.powf(lines / WHEEL_NOTCHES_PER_DOUBLING)).min(WHEEL_ZOOM_MAX); + if !scale.is_finite() { + return; + } + self.zoom_arm = false; + if scale <= 1.0 + 1e-3 { + self.zoom = None; + return; + } + // The new window puts (nx, ny) at the same place on screen as before. + let w = 1.0 / scale; + let fx = (p.x - srect.min.x) / srect.width(); + let fy = (p.y - srect.min.y) / srect.height(); + let x = (nx - fx * w).clamp(0.0, 1.0 - w); + let y = (ny - fy * w).clamp(0.0, 1.0 - w); + self.zoom = Some(Zoom { nx: x + w * 0.5, ny: y + w * 0.5, scale }); + } + + /// Esc closes the See All Slides of the window it was pressed in, then a zoom, and only then leaves the show. + pub(super) fn back_out(&mut self, presenter: bool) -> bool { + if presenter && self.presenter_grid { + self.presenter_grid = false; + true + } else if !presenter && self.grid { + self.grid = false; + true + } else { + self.dismiss_zoom() + } + } + + /// Esc backs out of a zoom before it leaves the show. True when it did. + pub(super) fn dismiss_zoom(&mut self) -> bool { + if self.zoom.is_some() || self.zoom_arm { + self.zoom = None; + self.zoom_arm = false; + true + } else { + false + } + } + + pub fn blank_name(&self) -> Option<&'static str> { + match self.blank { + Some(c) if c == Color32::BLACK => Some("black"), + Some(c) if c == Color32::WHITE => Some("white"), + Some(_) => Some("blank"), + None => None, + } + } + + pub fn zoom_view(&self) -> Option<(f32, f32, f32)> { + self.zoom.map(|z| (z.nx, z.ny, z.scale)) + } + + pub(super) fn push_slide_number(&mut self, digit: char, now: f64) { + if !digit.is_ascii_digit() || self.goto.len() >= GOTO_DIGITS { + return; + } + self.goto.push(digit); + self.goto_at = if now.is_finite() { now } else { 0.0 }; + } + + /// 1-based slide number, or `None` for 0 or a non-number. The buffer is cleared either way. + pub(super) fn take_goto(&mut self) -> Option { + let raw = std::mem::take(&mut self.goto); + raw.parse::().ok().and_then(|n| n.checked_sub(1)) + } + + pub(super) fn expire_goto(&mut self, now: f64) { + if self.goto.is_empty() || !now.is_finite() || !self.goto_at.is_finite() { + return; + } + if now - self.goto_at > GOTO_HOLD { + self.goto.clear(); + } + } + + /// Esc drops a number in progress. True when it did. + pub(super) fn clear_goto(&mut self) -> bool { + if self.goto.is_empty() { + false + } else { + self.goto.clear(); + true + } + } + + pub(super) fn nudge_notes(&mut self, delta: f32) { + let next = self.notes_px + delta; + if next.is_finite() { + self.notes_px = next.clamp(NOTES_MIN, NOTES_MAX); + } + } + + pub fn showing_value(&self, n_slides: usize, aspect: f32, full: Rect, now: f64) -> serde_json::Value { + let mut v = json!({ + "slide": self.state.slide, + "step": self.state.step, + "ended": self.ended, + "grid": self.grid, + "zoomArm": self.zoom_arm, + "blank": self.blank_name(), + "zoom": self.zoom_view().map(|(x, y, scale)| json!({"x": x, "y": y, "scale": scale})), + "presenterPaused": self.timer.paused(), + "presenterSecs": self.timer.elapsed(now), + "notesPx": self.notes_px, + "custom": self.state.custom_name(), + "goto": if self.goto.is_empty() { None } else { Some(self.goto.clone()) }, + }); + if self.grid { + let slides = grid_indices(n_slides); + let cells = see_all_cells(full, slides.len(), aspect); + v["cells"] = + serde_json::Value::Array(slides.iter().zip(cells).map(|(i, r)| json!({"slide": i, "x": r.center().x, "y": r.center().y})).collect()); + } + v + } + + pub(super) fn reading_bar_hit(&self, ctx: &egui::Context) -> bool { + // Clicks on the on-screen controls must not also advance. + ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| { + let r = ctx.content_rect(); + (self.reading && p.y > r.max.y - 34.0) || (!self.reading && tool_bar_rect(r).expand(8.0).contains(p)) + }) + } +} + +const GOTO_HOLD: f64 = 3.0; + +pub(super) const GOTO_DIGITS: usize = 4; + +const ZOOM_SCALE: f32 = 2.0; + +/// Four notches double the zoom, so the wheel passes through the click-zoom's 2× on its way to 4×. +const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0; + +/// PowerPoint's slide show magnifier goes up to 400%. +const WHEEL_ZOOM_MAX: f32 = 4.0; + +pub(super) const GRID_CAP: usize = 48; + +const TOOL_BAR_W: f32 = 520.0; + +#[derive(Clone, Copy)] +pub(super) struct Zoom { + pub(super) nx: f32, + pub(super) ny: f32, + pub(super) scale: f32, +} + +impl Zoom { + pub(super) fn at(nx: f32, ny: f32) -> Self { + Zoom { nx: finite_unit(nx), ny: finite_unit(ny), scale: ZOOM_SCALE } + } + + pub(super) fn uv(self) -> Rect { + zoom_uv(self.nx, self.ny, self.scale) + } +} + +fn finite_unit(n: f32) -> f32 { + if n.is_finite() { n.clamp(0.0, 1.0) } else { 0.5 } +} + +pub(super) fn plain(m: egui::Modifiers) -> bool { + !m.command && !m.ctrl && !m.alt && !m.shift +} + +pub(super) fn unit_uv() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) +} + +pub(super) fn slide_aspect(doc: &Presentation) -> f32 { + let a = doc.slide_size.width as f32 / doc.slide_size.height.max(1.0) as f32; + if a.is_finite() && a > 0.05 { a } else { 16.0 / 9.0 } +} + +/// The part of the slide a zoom shows, in 0–1 texture space. The window stays on the slide. +pub(super) fn zoom_uv(nx: f32, ny: f32, scale: f32) -> Rect { + let scale = if scale.is_finite() && scale > 1.0 { scale } else { ZOOM_SCALE }; + let w = 1.0 / scale; + if w >= 1.0 { + return unit_uv(); + } + let x = (nx - w * 0.5).clamp(0.0, 1.0 - w); + let y = (ny - w * 0.5).clamp(0.0, 1.0 - w); + Rect::from_min_size(pos2(x, y), vec2(w, w)) +} + +/// The Ctrl/Cmd+wheel movement this frame in wheel lines (up is positive), `None` when there was +/// none. Read from the raw events: egui's `zoom_delta` also carries the trackpad pinch, which is +/// not a show gesture. +pub(super) fn wheel_zoom_lines(events: &[egui::Event], line_points: f32) -> Option { + let mut lines = None; + for event in events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event + && (modifiers.ctrl || modifiers.command) + { + let along = if delta.x.abs() > delta.y.abs() { delta.x } else { delta.y }; + let n = match unit { + egui::MouseWheelUnit::Point => along / line_points.max(1.0), + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => along, + }; + *lines.get_or_insert(0.0) += if n.is_finite() { n } else { 0.0 }; + } + } + lines +} + +pub(super) fn zoom_frame(slide: Rect, uv: Rect) -> Rect { + Rect::from_min_size( + pos2(slide.min.x + uv.min.x * slide.width(), slide.min.y + uv.min.y * slide.height()), + vec2(uv.width() * slide.width(), uv.height() * slide.height()), + ) +} + +pub(super) fn toggle_blank(show: &mut Show, color: Color32) { + show.blank = if show.blank == Some(color) { None } else { Some(color) }; +} + +pub(super) fn tool_bar_rect(full: Rect) -> Rect { + let w = TOOL_BAR_W.min((full.width() - 32.0).max(32.0)); + Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 50.0), vec2(w, 34.0)) +} + +pub(super) fn grid_indices(n: usize) -> Vec { + (0..n.min(GRID_CAP)).collect() +} + +/// Thumbnail rectangles for `n` slides inside `full`, largest 16:9-style thumbs that fit. +pub(super) fn see_all_cells(full: Rect, n: usize, aspect: f32) -> Vec { + if n == 0 || full.width() < 16.0 || full.height() < 16.0 { + return vec![]; + } + let aspect = if aspect.is_finite() && aspect > 0.05 { aspect } else { 16.0 / 9.0 }; + let margin = 28.0; + let gap = 16.0; + let label_h = 22.0; + let inner = full.shrink(margin); + let inner = Rect::from_min_max(pos2(inner.min.x, inner.min.y + 36.0), inner.max); + if inner.width() < 8.0 || inner.height() < 8.0 { + return vec![]; + } + let mut best: Vec = vec![]; + let mut best_area = 0.0_f32; + for cols in 1..=n.min(8) { + let rows = n.div_ceil(cols); + let cell_w = (inner.width() - gap * (cols.saturating_sub(1) as f32)) / cols as f32; + let cell_h = (inner.height() - gap * (rows.saturating_sub(1) as f32)) / rows as f32; + if cell_w < 8.0 || cell_h < label_h + 8.0 { + continue; + } + let thumb_max_h = cell_h - label_h; + let mut tw = cell_w; + let mut th = tw / aspect; + if th > thumb_max_h { + th = thumb_max_h; + tw = th * aspect; + } + if tw < 8.0 || th < 8.0 { + continue; + } + let area = tw * th; + if area <= best_area { + continue; + } + best_area = area; + best = (0..n) + .map(|i| { + let c = i % cols; + let r = i / cols; + let ox = inner.min.x + c as f32 * (cell_w + gap) + (cell_w - tw) * 0.5; + let oy = inner.min.y + r as f32 * (cell_h + gap); + Rect::from_min_size(pos2(ox, oy), vec2(tw, th)) + }) + .collect(); + } + best +} + +pub(super) fn see_all_hit(full: Rect, n: usize, aspect: f32, p: Pos2) -> Option { + see_all_cells(full, n, aspect).into_iter().position(|r| r.expand2(vec2(4.0, 18.0)).contains(p)) +} + +pub(super) fn norm_on_slide(srect: Rect, uv: Rect, p: Pos2) -> Option<(f32, f32)> { + if srect.width() <= 1.0 || srect.height() <= 1.0 || !srect.contains(p) { + return None; + } + let u = (p.x - srect.min.x) / srect.width(); + let v = (p.y - srect.min.y) / srect.height(); + let nx = uv.min.x + u * uv.width(); + let ny = uv.min.y + v * uv.height(); + (nx.is_finite() && ny.is_finite()).then_some((nx.clamp(0.0, 1.0), ny.clamp(0.0, 1.0))) +} + +pub(super) fn slide_to_screen(srect: Rect, uv: Rect, nx: f32, ny: f32) -> Pos2 { + let u = (nx - uv.min.x) / uv.width().max(1e-4); + let v = (ny - uv.min.y) / uv.height().max(1e-4); + pos2(srect.min.x + u * srect.width(), srect.min.y + v * srect.height()) +} + +pub(super) fn slide_box(srect: Rect, uv: Rect, slide_w: f32, slide_h: f32, x: f32, y: f32, w: f32, h: f32) -> Rect { + let a = slide_to_screen(srect, uv, x / slide_w.max(1.0), y / slide_h.max(1.0)); + let b = slide_to_screen(srect, uv, (x + w) / slide_w.max(1.0), (y + h) / slide_h.max(1.0)); + Rect::from_min_max(a, b) +} + +pub(super) fn paint_grid(painter: &egui::Painter, ctx: &egui::Context, show: &mut Show, doc: &Presentation, full: Rect) { + let slides = grid_indices(doc.slides.len()); + let aspect = slide_aspect(doc); + let cells = see_all_cells(full, slides.len(), aspect); + painter.rect_filled(full, CornerRadius::ZERO, Color32::from_gray(16)); + painter.text(pos2(full.center().x, full.min.y + 28.0), Align2::CENTER_CENTER, "All slides", theme::font(20.0), Color32::WHITE); + if doc.slides.len() > GRID_CAP { + painter.text( + pos2(full.center().x, full.min.y + 50.0), + Align2::CENTER_CENTER, + format!("First {GRID_CAP} slides"), + theme::font(13.0), + Color32::from_gray(180), + ); + } + let budget = if slides.len() <= 12 { 12 } else { 4 }; + let mut made = 0; + for (i, rect) in slides.iter().copied().zip(cells) { + painter.rect_filled(rect, CornerRadius::same(4), Color32::from_gray(32)); + let tex = match show.thumbs.iter().find(|(k, _)| *k == i).map(|(_, t)| t.clone()) { + Some(t) => Some(t), + None if made < budget => { + made += 1; + render_thumb(ctx, show, doc, i) + } + None => { + ctx.request_repaint(); + None + } + }; + if let Some(t) = tex { + painter.image(t.id(), rect, unit_uv(), Color32::WHITE); + } + let current = i == show.state.slide; + painter.rect_stroke( + rect, + CornerRadius::same(4), + Stroke::new(if current { 3.0 } else { 1.0 }, if current { Color32::WHITE } else { Color32::from_gray(70) }), + egui::StrokeKind::Inside, + ); + let hidden = doc.slides.get(i).is_some_and(|s| s.hidden); + let label = if hidden { format!("{} Hidden", i + 1) } else { format!("{}", i + 1) }; + painter.text( + pos2(rect.center().x, rect.max.y + 12.0), + Align2::CENTER_CENTER, + label, + theme::font(13.0), + if current { Color32::WHITE } else { Color32::from_gray(180) }, + ); + } +} From 4bdd5b5beec1d3c3bd97a717ad46a1cdde52c54f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:09 +0000 Subject: [PATCH 46/90] refactor(ui-egui): split icons.rs into icons/{geometry,draw}.rs --- crates/ui-egui/src/icons.rs | 2100 +------------------------- crates/ui-egui/src/icons/draw.rs | 1341 ++++++++++++++++ crates/ui-egui/src/icons/geometry.rs | 763 ++++++++++ 3 files changed, 2112 insertions(+), 2092 deletions(-) create mode 100644 crates/ui-egui/src/icons/draw.rs create mode 100644 crates/ui-egui/src/icons/geometry.rs diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 545091415..c3aa288b7 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -57,6 +57,14 @@ macro_rules! icons { }; } +mod draw; +mod geometry; + +use draw::draw; +use geometry::Pen; +#[cfg(test)] +use geometry::{star_pts, triangulate}; + icons! { // Clipboard & slides Paste => "paste", @@ -345,2098 +353,6 @@ pub fn paint(painter: &egui::Painter, rect: egui::Rect, icon: Icon, ink: egui::C draw(&pen, icon); } -// --------------------------------------------------------------------------------------------- -// Drawing toolkit -// --------------------------------------------------------------------------------------------- - -/// A logical colour, resolved against the theme ink and the disabled flag. -#[derive(Clone, Copy)] -enum C { - Ink(f32), - Acc(Color32, f32), - White, - None, -} - -impl C { - fn a(self, f: f32) -> C { - match self { - C::Ink(a) => C::Ink(a * f), - C::Acc(c, a) => C::Acc(c, a * f), - other => other, - } - } -} - -const K: C = C::Ink(1.0); -const B: C = C::Acc(BLUE, 1.0); -const O: C = C::Acc(ORANGE, 1.0); -const G: C = C::Acc(GREEN, 1.0); -const PU: C = C::Acc(PURPLE, 1.0); -const R: C = C::Acc(RED, 1.0); -const Y: C = C::Acc(GOLD, 1.0); -const D: C = C::Acc(DARK, 1.0); -const W: C = C::White; -const N: C = C::None; - -/// Light tint of an accent, for fills behind outlines. -fn t(c: C) -> C { - c.a(0.25) -} - -type P2 = (f32, f32); - -#[derive(Clone, Copy)] -enum Glyph { - Plus, - Cross, - Check, -} - -struct Pen<'a> { - p: &'a Painter, - o: Pos2, - /// Points per design unit. - k: f32, - /// Base stroke width in points. - sw: f32, - ink: Color32, - dis: bool, -} - -fn rad(deg: f32) -> f32 { - deg * PI / 180.0 -} - -fn arc_pts(c: P2, rx: f32, ry: f32, a0: f32, a1: f32) -> Vec { - let n = (((a1 - a0).abs() / 9.0).ceil() as usize).max(4); - (0..=n) - .map(|i| { - let a = rad(a0 + (a1 - a0) * i as f32 / n as f32); - (c.0 + rx * a.cos(), c.1 + ry * a.sin()) - }) - .collect() -} - -fn cubic_pts(p0: P2, p1: P2, p2: P2, p3: P2, n: usize) -> Vec { - (0..=n) - .map(|i| { - let t = i as f32 / n as f32; - let u = 1.0 - t; - let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); - (a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1) - }) - .collect() -} - -fn quad_pts(p0: P2, p1: P2, p2: P2, n: usize) -> Vec { - (0..=n) - .map(|i| { - let t = i as f32 / n as f32; - let u = 1.0 - t; - (u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1) - }) - .collect() -} - -/// Rounds the corners of a polyline by `r` design units. -fn round_pts(pts: &[P2], r: f32, closed: bool) -> Vec { - let n = pts.len(); - if n < 3 || r <= 0.0 { - return pts.to_vec(); - } - let mut out = Vec::with_capacity(n * 6); - for i in 0..n { - let cur = pts[i]; - let interior = closed || (i > 0 && i + 1 < n); - if !interior { - out.push(cur); - continue; - } - let prev = pts[(i + n - 1) % n]; - let next = pts[(i + 1) % n]; - let d1 = ((prev.0 - cur.0).powi(2) + (prev.1 - cur.1).powi(2)).sqrt(); - let d2 = ((next.0 - cur.0).powi(2) + (next.1 - cur.1).powi(2)).sqrt(); - if d1 < 1e-4 || d2 < 1e-4 { - out.push(cur); - continue; - } - let rr = r.min(d1 * 0.5).min(d2 * 0.5); - let a = (cur.0 + (prev.0 - cur.0) / d1 * rr, cur.1 + (prev.1 - cur.1) / d1 * rr); - let b = (cur.0 + (next.0 - cur.0) / d2 * rr, cur.1 + (next.1 - cur.1) / d2 * rr); - out.extend(quad_pts(a, cur, b, 4)); - } - out -} - -fn rrect_pts(x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> Vec { - round_pts(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)], r, true) -} - -/// Star polygon with `n` points. -fn star_pts(c: P2, ro: f32, ri: f32, n: usize, rot: f32) -> Vec { - (0..n * 2) - .map(|i| { - let r = if i % 2 == 0 { ro } else { ri }; - let a = rad(rot - 90.0 + i as f32 * 180.0 / n as f32); - (c.0 + r * a.cos(), c.1 + r * a.sin()) - }) - .collect() -} - -/// Transforms local points (u along `ang`, v across) to design coordinates around `c`. -fn tf(c: P2, ang: f32, pts: &[P2]) -> Vec { - let (s, co) = rad(ang).sin_cos(); - pts.iter().map(|&(u, v)| (c.0 + u * co - v * s, c.1 + u * s + v * co)).collect() -} - -fn point_in_tri(p: Pos2, a: Pos2, b: Pos2, c: Pos2) -> bool { - let d1 = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y); - let d2 = (p.x - c.x) * (b.y - c.y) - (b.x - c.x) * (p.y - c.y); - let d3 = (p.x - a.x) * (c.y - a.y) - (c.x - a.x) * (p.y - a.y); - let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; - let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; - !(neg && pos) -} - -/// Ear-clipping triangulation of a simple polygon. -fn triangulate(pts: &[Pos2]) -> Vec<[u32; 3]> { - let n = pts.len(); - let mut tris = Vec::new(); - if n < 3 { - return tris; - } - let area: f32 = (0..n) - .map(|i| { - let a = pts[i]; - let b = pts[(i + 1) % n]; - a.x * b.y - b.x * a.y - }) - .sum(); - let mut idx: Vec = (0..n).collect(); - if area < 0.0 { - idx.reverse(); - } - while idx.len() > 3 { - let m = idx.len(); - let mut clipped = false; - for i in 0..m { - let (ia, ib, ic) = (idx[(i + m - 1) % m], idx[i], idx[(i + 1) % m]); - let (a, b, c) = (pts[ia], pts[ib], pts[ic]); - let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); - if cross <= 1e-7 { - continue; - } - let blocked = idx.iter().any(|&j| j != ia && j != ib && j != ic && point_in_tri(pts[j], a, b, c)); - if blocked { - continue; - } - tris.push([ia as u32, ib as u32, ic as u32]); - idx.remove(i); - clipped = true; - break; - } - if !clipped { - // Degenerate input: drop a vertex and carry on rather than loop forever. - idx.remove(0); - } - } - if let [a, b, c] = idx[..] { - tris.push([a as u32, b as u32, c as u32]); - } - tris -} - -impl Pen<'_> { - fn pt(&self, (x, y): P2) -> Pos2 { - pos2(self.o.x + x * self.k, self.o.y + y * self.k) - } - - fn v(&self, pts: &[P2]) -> Vec { - pts.iter().map(|&p| self.pt(p)).collect() - } - - fn col(&self, c: C) -> Color32 { - let dim = if self.dis { 0.35 } else { 1.0 }; - match c { - C::Ink(a) => self.ink.gamma_multiply(a * dim), - C::Acc(col, a) => { - if self.dis { - self.ink.gamma_multiply(a * dim) - } else { - col.gamma_multiply(a) - } - } - C::White => { - if self.dis { - // Glyphs on solid fills stay legible as a darker mark. - self.ink.gamma_multiply(0.6) - } else { - Color32::WHITE - } - } - C::None => Color32::TRANSPARENT, - } - } - - fn stroke(&self, c: C, w: f32) -> Stroke { - Stroke::new(self.sw * w, self.col(c)) - } - - // --- lines --- - - fn line_w(&self, pts: &[P2], c: C, w: f32) { - if pts.len() < 2 { - return; - } - let col = self.col(c); - if col == Color32::TRANSPARENT { - return; - } - let v = self.v(pts); - if let [a, b] = v[..] { - // Single segments get round caps: draw them as one convex capsule. - let d = b - a; - let len = d.length(); - let r = self.sw * w * 0.5; - if len < 1e-3 { - self.p.circle_filled(a, r, col); - return; - } - let ang = d.y.atan2(d.x); - let mut cap = Vec::with_capacity(20); - for (c, start) in [(b, ang - PI * 0.5), (a, ang + PI * 0.5)] { - for i in 0..=8 { - let t = start + PI * i as f32 / 8.0; - cap.push(c + vec2(t.cos(), t.sin()) * r); - } - } - self.p.add(PathShape::convex_polygon(cap, col, Stroke::NONE)); - return; - } - self.p.add(PathShape::line(v, Stroke::new(self.sw * w, col))); - } - - fn line(&self, pts: &[P2], c: C) { - self.line_w(pts, c, 1.0); - } - - fn seg(&self, a: P2, b: P2, c: C) { - self.line_w(&[a, b], c, 1.0); - } - - fn seg_w(&self, a: P2, b: P2, c: C, w: f32) { - self.line_w(&[a, b], c, w); - } - - fn rline(&self, pts: &[P2], r: f32, c: C) { - self.line(&round_pts(pts, r, false), c); - } - - fn rline_w(&self, pts: &[P2], r: f32, c: C, w: f32) { - self.line_w(&round_pts(pts, r, false), c, w); - } - - fn dashed(&self, pts: &[P2], c: C, dash: f32, gap: f32) { - let v = self.v(pts); - self.p.extend(Shape::dashed_line(&v, self.stroke(c, 1.0), dash * self.k, gap * self.k)); - } - - fn dashed_w(&self, pts: &[P2], c: C, dash: f32, gap: f32, w: f32) { - let v = self.v(pts); - self.p.extend(Shape::dashed_line(&v, self.stroke(c, w), dash * self.k, gap * self.k)); - } - - fn dotted(&self, pts: &[P2], c: C, spacing: f32, r: f32) { - let v = self.v(pts); - self.p.extend(Shape::dotted_line(&v, self.col(c), spacing * self.k, r * self.sw)); - } - - // --- filled shapes --- - - /// Any simple polygon: filled via triangulation, stroked as a closed path. - fn shape_w(&self, pts: &[P2], fill: C, stroke: C, w: f32) { - if pts.len() < 3 { - return; - } - let v = self.v(pts); - let fc = self.col(fill); - if fc != Color32::TRANSPARENT { - let mut mesh = Mesh::default(); - for &p in &v { - mesh.colored_vertex(p, fc); - } - for [a, b, c] in triangulate(&v) { - mesh.add_triangle(a, b, c); - } - self.p.add(Shape::mesh(mesh)); - // A hairline in the fill colour anti-aliases the mesh edge. - if matches!(stroke, C::None) { - self.p.add(PathShape::closed_line(v.clone(), Stroke::new(0.6_f32.min(self.sw * 0.5), fc))); - } - } - let sc = self.col(stroke); - if sc != Color32::TRANSPARENT { - self.p.add(PathShape::closed_line(v, Stroke::new(self.sw * w, sc))); - } - } - - fn shape(&self, pts: &[P2], fill: C, stroke: C) { - self.shape_w(pts, fill, stroke, 1.0); - } - - fn rshape(&self, pts: &[P2], r: f32, fill: C, stroke: C) { - self.shape(&round_pts(pts, r, true), fill, stroke); - } - - /// Convex polygon (anti-aliased fill). - fn poly(&self, pts: &[P2], fill: C, stroke: C) { - self.poly_w(pts, fill, stroke, 1.0); - } - - fn poly_w(&self, pts: &[P2], fill: C, stroke: C, w: f32) { - let v = self.v(pts); - self.p.add(PathShape::convex_polygon(v, self.col(fill), self.stroke(stroke, w))); - } - - fn rect_w(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C, w: f32) { - let rect = Rect::from_min_max(self.pt((x0, y0)), self.pt((x1, y1))); - self.p.rect(rect, r * self.k, self.col(fill), self.stroke(stroke, w), StrokeKind::Middle); - } - - fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C) { - self.rect_w(x0, y0, x1, y1, r, fill, stroke, 1.0); - } - - fn circle_w(&self, c: P2, r: f32, fill: C, stroke: C, w: f32) { - self.p.circle(self.pt(c), r * self.k, self.col(fill), self.stroke(stroke, w)); - } - - fn circle(&self, c: P2, r: f32, fill: C, stroke: C) { - self.circle_w(c, r, fill, stroke, 1.0); - } - - fn dot(&self, c: P2, r: f32, fill: C) { - self.p.circle_filled(self.pt(c), r * self.k, self.col(fill)); - } - - fn ellipse(&self, c: P2, rx: f32, ry: f32, fill: C, stroke: C) { - let mut pts = arc_pts(c, rx, ry, 0.0, 360.0); - pts.pop(); - self.poly(&pts, fill, stroke); - } - - // --- arcs and arrows --- - - fn arc(&self, c: P2, r: f32, a0: f32, a1: f32, col: C) { - self.line(&arc_pts(c, r, r, a0, a1), col); - } - - /// Filled arrow head with its tip at `tip`, pointing along `dir` degrees. - fn head(&self, tip: P2, dir: f32, size: f32, c: C) { - let (s, co) = rad(dir).sin_cos(); - let hw = size * 0.62; - let back = (tip.0 - co * size, tip.1 - s * size); - self.poly(&[tip, (back.0 - s * hw, back.1 + co * hw), (back.0 + s * hw, back.1 - co * hw)], c, N); - } - - fn head_size(&self) -> f32 { - (2.2 * self.sw / self.k).max(3.6) - } - - fn arrow(&self, a: P2, b: P2, c: C) { - let dir = (b.1 - a.1).atan2(b.0 - a.0); - let hs = self.head_size(); - let end = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); - self.seg(a, end, c); - self.head(b, dir.to_degrees(), hs, c); - } - - fn arrow2(&self, a: P2, b: P2, c: C) { - let dir = (b.1 - a.1).atan2(b.0 - a.0); - let hs = self.head_size(); - let s = (a.0 + dir.cos() * hs * 0.7, a.1 + dir.sin() * hs * 0.7); - let e = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); - self.seg(s, e, c); - self.head(b, dir.to_degrees(), hs, c); - self.head(a, dir.to_degrees() + 180.0, hs, c); - } - - /// Polyline ending in an arrow head. - fn path_arrow(&self, pts: &[P2], c: C) { - let n = pts.len(); - if n < 2 { - return; - } - let a = pts[n - 2]; - let b = pts[n - 1]; - let dir = (b.1 - a.1).atan2(b.0 - a.0); - let hs = self.head_size(); - let mut body = pts.to_vec(); - body[n - 1] = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); - self.line(&body, c); - self.head(b, dir.to_degrees(), hs, c); - } - - /// Circular arc from `a0` to `a1` degrees with an arrow head at the `a1` end. - fn arc_arrow(&self, c: P2, r: f32, a0: f32, a1: f32, col: C) { - let hs = self.head_size(); - let sign = if a1 >= a0 { 1.0 } else { -1.0 }; - let back = (hs * 0.6 / r).to_degrees() * sign; - self.line(&arc_pts(c, r, r, a0, a1 - back), col); - let tip_a = rad(a1); - let tip = (c.0 + r * tip_a.cos(), c.1 + r * tip_a.sin()); - // Tangent direction of travel, nudged inward to sit on the arc. - let dir = a1 + 90.0 * sign - (hs * 0.5 / r).to_degrees() * sign; - self.head(tip, dir, hs, col); - } - - fn curve(&self, p0: P2, p1: P2, p2: P2, p3: P2, c: C) { - self.line(&cubic_pts(p0, p1, p2, p3, 24), c); - } - - fn curve_w(&self, p0: P2, p1: P2, p2: P2, p3: P2, c: C, w: f32) { - self.line_w(&cubic_pts(p0, p1, p2, p3, 24), c, w); - } - - // --- text --- - - fn text(&self, s: &str, at: P2, size: f32, c: C) { - self.p.text(self.pt(at), Align2::CENTER_CENTER, s, FontId::proportional(size * self.k), self.col(c)); - } - - fn text_bold(&self, s: &str, at: P2, size: f32, c: C) { - let d = 0.32 * self.sw / self.k; - let reps: &[f32] = if self.dis { &[0.0] } else { &[-d, 0.0, d] }; - for &dx in reps { - self.text(s, (at.0 + dx, at.1), size, c); - } - } - - fn text_rot(&self, s: &str, at: P2, size: f32, c: C, angle_deg: f32) { - let col = self.col(c); - let galley = self.p.layout_no_wrap(s.to_owned(), FontId::proportional(size * self.k), col); - let rot = egui::emath::Rot2::from_angle(rad(angle_deg)); - let pos = self.pt(at) - rot * (galley.size() * 0.5); - self.p.add(TextShape::new(pos, galley, col).with_angle(rad(angle_deg))); - } - - // --- composite parts --- - - /// Round badge with a white glyph (outline + ink glyph when disabled). - fn badge(&self, c: P2, r: f32, fill: C, glyph: Glyph) { - let gc = if self.dis { - self.circle(c, r, N, fill); - K - } else { - self.circle(c, r, fill, N); - W - }; - let g = r * 0.5; - let w = 0.95; - match glyph { - Glyph::Plus => { - self.seg_w((c.0 - g, c.1), (c.0 + g, c.1), gc, w); - self.seg_w((c.0, c.1 - g), (c.0, c.1 + g), gc, w); - } - Glyph::Cross => { - let g = g * 0.85; - self.seg_w((c.0 - g, c.1 - g), (c.0 + g, c.1 + g), gc, w); - self.seg_w((c.0 + g, c.1 - g), (c.0 - g, c.1 + g), gc, w); - } - Glyph::Check => { - self.line_w(&[(c.0 - g, c.1 + 0.1), (c.0 - g * 0.25, c.1 + g * 0.75), (c.0 + g, c.1 - g * 0.6)], gc, w); - } - } - } - - /// Landscape slide frame. - fn frame(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - self.rect(x0, y0, x1, y1, 1.5, N, K); - } - - /// Document page with a folded top-right corner. - fn page(&self, x0: f32, y0: f32, x1: f32, y1: f32, fill: C, stroke: C) { - let f = ((x1 - x0) * 0.32).min(5.0); - self.rshape(&[(x0, y0), (x1 - f, y0), (x1, y0 + f), (x1, y1), (x0, y1)], 1.2, fill, stroke); - self.line(&[(x1 - f, y0), (x1 - f, y0 + f), (x1, y0 + f)], stroke); - } - - /// Picture frame with sun and hills. - fn picture(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - let (w, h) = (x1 - x0, y1 - y0); - let m = w.min(h); - self.rect(x0, y0, x1, y1, 1.5, N, K); - self.dot((x1 - w * 0.27, y0 + h * 0.3), m * 0.12, Y); - let base = y1 - h * 0.14; - self.poly(&[(x0 + w * 0.12, base), (x0 + w * 0.38, y0 + h * 0.36), (x0 + w * 0.64, base)], G, N); - self.poly(&[(x0 + w * 0.48, base), (x0 + w * 0.68, y0 + h * 0.56), (x1 - w * 0.12, base)], G.a(0.7), N); - } - - fn magnifier(&self, c: P2, r: f32, lens_fill: C, col: C) { - self.circle(c, r, lens_fill, col); - let d = r * 0.707; - self.seg_w((c.0 + d + 0.6, c.1 + d + 0.6), (c.0 + r * 1.55, c.1 + r * 1.55), col, 1.5); - } - - fn bubble_pts(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { - let r = 2.5; - let mut pts = arc_pts((x0 + r, y0 + r), r, r, 180.0, 270.0); - pts.extend(arc_pts((x1 - r, y0 + r), r, r, 270.0, 360.0)); - pts.extend(arc_pts((x1 - r, y1 - r), r, r, 0.0, 90.0)); - let tw = ((x1 - x0) * 0.22).clamp(2.5, 4.5); - pts.push((x0 + r + 0.8 + tw, y1)); - pts.push((x0 + r - 0.2, y1 + 3.8)); - pts.push((x0 + r + 0.8, y1)); - pts.extend(arc_pts((x0 + r, y1 - r), r, r, 90.0, 180.0)); - pts - } - - fn bubble(&self, x0: f32, y0: f32, x1: f32, y1: f32, fill: C, stroke: C) { - self.shape(&Self::bubble_pts(x0, y0, x1, y1), fill, stroke); - } - - fn person(&self, cx: f32, top: f32, s: f32, fill: C) { - self.dot((cx, top + 2.6 * s), 2.6 * s, fill); - let base = top + 12.0 * s; - self.poly(&arc_pts((cx, base), 5.2 * s, 5.0 * s, 180.0, 360.0), fill, N); - } - - fn star(&self, c: P2, ro: f32, fill: C, stroke: C) { - self.shape(&star_pts(c, ro, ro * 0.47, 5, 0.0), fill, stroke); - } - - fn sparkle(&self, c: P2, r: f32, fill: C) { - self.shape(&star_pts(c, r, r * 0.3, 4, 0.0), fill, N); - } - - fn gear(&self, c: P2, ro: f32, ri: f32, teeth: usize, fill: C, stroke: C) { - let step = 360.0 / teeth as f32; - let mut pts = Vec::with_capacity(teeth * 4); - for i in 0..teeth { - let base = i as f32 * step; - for (a, r) in [(-0.3, ri), (-0.17, ro), (0.17, ro), (0.3, ri)] { - let ang = rad(base + a * step); - pts.push((c.0 + r * ang.cos(), c.1 + r * ang.sin())); - } - } - self.shape(&pts, fill, stroke); - } - - fn speaker(&self, x: f32, cy: f32, fill: C, stroke: C) { - self.rshape( - &[(x, cy - 3.0), (x + 3.8, cy - 3.0), (x + 8.5, cy - 7.5), (x + 8.5, cy + 7.5), (x + 3.8, cy + 3.0), (x, cy + 3.0)], - 0.8, - fill, - stroke, - ); - } - - /// Table grid; `header` fills the first row. - fn table(&self, x0: f32, y0: f32, x1: f32, y1: f32, rows: usize, cols: usize, header: C) { - let rh = (y1 - y0) / rows as f32; - let cw = (x1 - x0) / cols as f32; - if !matches!(header, C::None) { - self.rect(x0, y0, x1, y0 + rh, 1.2, header, N); - } - for i in 1..rows { - let y = y0 + rh * i as f32; - self.seg_w((x0, y), (x1, y), K, 0.8); - } - for j in 1..cols { - let x = x0 + cw * j as f32; - self.seg_w((x, y0), (x, y1), K, 0.8); - } - self.rect(x0, y0, x1, y1, 1.2, N, K); - } - - /// Bar chart in the box. - fn bars(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - let w = (x1 - x0) / 3.0; - let h = y1 - y0; - for (i, (frac, c)) in [(0.5, B), (1.0, O), (0.72, G)].into_iter().enumerate() { - let bx = x0 + w * i as f32; - self.rect(bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1, 0.6, c, N); - } - self.seg((x0 - 0.5, y1 + 0.2), (x1 + 0.5, y1 + 0.2), K); - } - - /// A writing tool lying along `ang` degrees with its point at `tip`. - fn tool(&self, tip: P2, ang: f32, len: f32, hw: f32, cone: f32, body: C, lead: C, cap_len: f32, cap: C) { - let m = |pts: &[P2]| tf(tip, ang, pts); - self.poly(&m(&[(0.0, 0.0), (cone, -hw), (cone, hw)]), N, K); - self.poly(&m(&[(0.0, 0.0), (cone * 0.42, -hw * 0.42), (cone * 0.42, hw * 0.42)]), lead, N); - self.poly(&m(&[(cone, -hw), (len - cap_len, -hw), (len - cap_len, hw), (cone, hw)]), body, K); - if cap_len > 0.0 { - self.poly(&m(&[(len - cap_len, -hw), (len, -hw), (len, hw), (len - cap_len, hw)]), cap, K); - } - } - - /// Paint brush lying along `ang` with bristle tip at `tip`. - fn brush(&self, tip: P2, ang: f32, len: f32, bristle: C) { - let m = |pts: &[P2]| tf(tip, ang, pts); - self.shape(&m(&[(0.0, -0.4), (2.5, -2.2), (5.5, -2.2), (5.5, 2.2), (2.5, 2.2), (0.0, 0.4)]), bristle, K); - self.poly(&m(&[(5.5, -2.0), (7.8, -2.0), (7.8, 2.0), (5.5, 2.0)]), K, K); - self.line(&m(&[(7.8, -1.1), (len, -1.3), (len, 1.3), (7.8, 1.1)]), K); - } - - /// Small path-drawn digit 1–3 in a `3 × h` box. - fn digit(&self, d: u8, x: f32, y: f32, h: f32, c: C) { - let w = 0.8; - match d { - 1 => self.line_w(&[(x + 0.4, y + 1.2), (x + 2.0, y), (x + 2.0, y + h)], c, w), - 2 => self.line_w(&[(x, y + 1.0), (x + 0.7, y), (x + 2.4, y), (x + 3.1, y + 1.1), (x + 2.9, y + 2.2), (x, y + h), (x + 3.2, y + h)], c, w), - _ => self.line_w( - &[(x, y), (x + 3.0, y), (x + 1.3, y + h * 0.42), (x + 2.6, y + h * 0.5), (x + 3.1, y + h * 0.7), (x + 2.5, y + h), (x, y + h)], - c, - w, - ), - } - } - - /// Path-drawn capital A. - fn letter_a(&self, x0: f32, top: f32, x1: f32, bottom: f32, c: C, w: f32) { - let mx = (x0 + x1) * 0.5; - self.line_w(&[(x0, bottom), (mx, top), (x1, bottom)], c, w); - let f = 0.62; - let y = top + (bottom - top) * f; - let dx = (x1 - x0) * 0.5 * f; - self.seg_w((mx - dx, y), (mx + dx, y), c, w); - } - - /// Hand-drawn scribble in the box. - fn scribble(&self, x0: f32, y0: f32, x1: f32, y1: f32, c: C) { - let (w, h) = (x1 - x0, y1 - y0); - let p = |u: f32, v: f32| (x0 + w * u, y0 + h * v); - // A loopy cursive stroke (prolate cycloid), normalised into the box. - let raw: Vec = (0..=48) - .map(|i| { - let th = i as f32 / 48.0 * 4.0 * PI + 0.6; - (th - 1.9 * th.sin(), -1.9 * th.cos()) - }) - .collect(); - let (mut lx, mut hx, mut ly, mut hy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN); - for &(x, y) in &raw { - lx = lx.min(x); - hx = hx.max(x); - ly = ly.min(y); - hy = hy.max(y); - } - let sx = (hx - lx).max(1e-3); - let sy = (hy - ly).max(1e-3); - let pts: Vec = raw.iter().map(|&(x, y)| p((x - lx) / sx, (y - ly) / sy)).collect(); - self.line(&pts, c); - } - - /// Isometric "layer" diamond. - fn layer(&self, cx: f32, cy: f32, hw: f32, hh: f32, fill: C, stroke: C) { - self.rshape(&[(cx, cy - hh), (cx + hw, cy), (cx, cy + hh), (cx - hw, cy)], 0.8, fill, stroke); - } - - /// Open book. - fn book(&self, fill: C, col: C) { - let left = { - let mut p = quad_pts((12.0, 7.0), (7.5, 4.0), (2.0, 5.0), 8); - p.extend(quad_pts((2.0, 18.5), (7.5, 17.5), (12.0, 20.5), 8)); - p - }; - let right = { - let mut p = quad_pts((12.0, 7.0), (16.5, 4.0), (22.0, 5.0), 8); - p.extend(quad_pts((22.0, 18.5), (16.5, 17.5), (12.0, 20.5), 8)); - p - }; - self.shape(&left, fill, col); - self.shape(&right, fill, col); - } - - fn monitor(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - self.rect(x0, y0, x1, y1, 1.5, N, K); - let cx = (x0 + x1) * 0.5; - self.seg((cx, y1), (cx, y1 + 3.2), K); - self.seg((cx - 4.0, y1 + 3.6), (cx + 4.0, y1 + 3.6), K); - } - - fn clock_badge(&self, c: P2, r: f32, fill: C) { - let hc = if self.dis { - self.circle(c, r, N, fill); - K - } else { - self.circle(c, r, fill, N); - W - }; - self.line_w(&[(c.0, c.1 - r * 0.6), c, (c.0 + r * 0.5, c.1 + r * 0.2)], hc, 0.9); - } - - fn gear_badge(&self, c: P2, r: f32, fill: C) { - self.gear(c, r, r * 0.74, 8, fill, N); - if self.dis { - self.gear(c, r, r * 0.74, 8, N, fill); - } - self.dot(c, r * 0.32, if self.dis { N } else { W }); - if self.dis { - self.circle(c, r * 0.32, N, fill); - } - } - - fn lock(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - let cx = (x0 + x1) * 0.5; - let r = (x1 - x0) * 0.3; - let mut sh = vec![(cx - r, y0)]; - sh.extend(arc_pts((cx, y0 - r * 0.7), r, r, 180.0, 360.0)); - sh.push((cx + r, y0)); - self.line(&sh, K); - self.rect(x0, y0, x1, y1, 1.2, Y, K); - let ky = (y0 + y1) * 0.5; - self.dot((cx, ky - 0.4), 1.1, D); - self.seg_w((cx, ky), (cx, ky + 2.0), D, 0.9); - } - - fn cursor(&self, x: f32, y: f32, s: f32) { - let p = |u: f32, v: f32| (x + u * s, y + v * s); - let pts = [p(0.0, 0.0), p(0.0, 15.0), p(3.8, 11.6), p(6.4, 17.0), p(8.6, 16.0), p(6.1, 10.8), p(11.0, 10.8)]; - self.shape(&pts, K, K); - } -} - -// --------------------------------------------------------------------------------------------- -// The icons -// --------------------------------------------------------------------------------------------- - -#[expect(clippy::too_many_lines)] -fn draw(p: &Pen<'_>, icon: Icon) { - use Icon as I; - match icon { - // ----- Clipboard & slides ----- - I::Paste => { - p.rline(&[(10.5, 21.5), (3.5, 21.5), (3.5, 4.5), (17.0, 4.5), (17.0, 9.0)], 2.0, K); - p.rect(7.0, 2.5, 13.5, 6.5, 1.2, Y, K); - p.rect(10.5, 10.0, 21.0, 22.0, 1.2, t(B), B); - p.seg((13.0, 14.0), (18.5, 14.0), B); - p.seg((13.0, 18.0), (17.0, 18.0), B); - } - I::Cut => { - p.seg((8.6, 14.6), (16.5, 2.5), K); - p.seg((15.4, 14.6), (7.5, 2.5), K); - p.circle((6.8, 17.6), 3.3, N, O); - p.circle((17.2, 17.6), 3.3, N, O); - p.dot((12.0, 9.4), 1.0, K); - } - I::Copy => { - p.rline(&[(8.0, 16.0), (3.0, 16.0), (3.0, 2.5), (14.0, 2.5), (14.0, 7.0)], 1.5, K); - p.rect(8.0, 7.5, 20.5, 21.5, 1.5, t(B), B); - p.seg((11.0, 12.5), (17.5, 12.5), B); - p.seg((11.0, 16.5), (16.0, 16.5), B); - } - I::FormatPainter => { - p.rect(2.5, 3.0, 17.0, 9.0, 1.5, Y, K); - p.rline(&[(17.0, 6.0), (20.5, 6.0), (20.5, 11.5), (11.0, 11.5), (11.0, 14.0)], 1.2, K); - p.rect(9.0, 14.0, 13.0, 21.5, 1.0, K, K); - } - I::NewSlide => { - p.frame(2.0, 4.0, 19.5, 16.5); - p.seg((5.0, 8.0), (13.0, 8.0), B); - p.seg((5.0, 12.0), (10.0, 12.0), K.a(0.5)); - p.badge((18.0, 17.5), 5.0, G, Glyph::Plus); - } - I::Layout => { - p.frame(2.0, 3.5, 22.0, 20.5); - p.rect(5.0, 6.5, 19.0, 9.0, 0.8, B, N); - p.rect(5.0, 11.5, 11.0, 17.5, 0.8, t(G), G); - p.rect(13.0, 11.5, 19.0, 17.5, 0.8, t(O), O); - } - I::ResetSlide => { - p.frame(2.0, 3.5, 22.0, 20.5); - p.arc_arrow((12.0, 12.0), 4.6, -20.0, -290.0, B); - } - I::Section => { - p.rshape(&[(2.5, 2.5), (13.0, 2.5), (16.5, 5.75), (13.0, 9.0), (2.5, 9.0)], 0.6, PU, N); - p.rline(&[(5.0, 9.0), (5.0, 16.0), (7.5, 16.0)], 1.0, K); - p.rect(7.5, 12.0, 21.5, 20.5, 1.2, N, K); - p.seg((10.0, 15.5), (16.0, 15.5), K.a(0.5)); - } - I::DuplicateSlide => { - p.rline(&[(6.5, 15.5), (2.5, 15.5), (2.5, 3.5), (16.5, 3.5), (16.5, 8.0)], 1.5, K); - p.rect(6.5, 8.0, 21.5, 20.5, 1.5, t(B), B); - p.seg((14.0, 11.5), (14.0, 17.0), B); - p.seg((11.25, 14.25), (16.75, 14.25), B); - } - I::DeleteSlide => { - p.frame(2.0, 4.0, 19.5, 16.5); - p.seg((5.0, 8.0), (13.0, 8.0), B); - p.seg((5.0, 12.0), (10.0, 12.0), K.a(0.5)); - p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); - } - I::HideSlide => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K.a(0.55)); - p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.55)); - p.seg((3.0, 21.0), (21.0, 3.0), R); - } - I::Slide => { - p.frame(2.0, 4.5, 22.0, 19.5); - p.seg((5.0, 8.5), (15.0, 8.5), B); - p.rect(5.0, 11.5, 19.0, 16.5, 0.8, t(B), N); - } - - // ----- Font ----- - I::FontGrow => { - p.letter_a(2.5, 4.0, 15.0, 20.5, K, 1.15); - p.poly(&[(16.0, 10.5), (22.5, 10.5), (19.25, 5.0)], B, N); - } - I::FontShrink => { - p.letter_a(3.0, 8.0, 13.5, 20.5, K, 1.0); - p.poly(&[(16.0, 5.0), (22.5, 5.0), (19.25, 10.5)], B, N); - } - I::ClearFormatting => { - p.letter_a(2.5, 3.0, 14.0, 17.5, K, 1.1); - let m = |pts: &[P2]| tf((17.0, 16.5), -45.0, pts); - p.poly(&m(&[(-5.5, -2.8), (0.0, -2.8), (0.0, 2.8), (-5.5, 2.8)]), R, R); - p.poly(&m(&[(0.0, -2.8), (5.5, -2.8), (5.5, 2.8), (0.0, 2.8)]), N, K); - } - I::Bold => p.text_bold("B", (12.0, 12.0), 22.0, K), - I::Italic => { - p.seg_w((10.5, 4.0), (18.5, 4.0), K, 1.1); - p.seg_w((5.5, 20.0), (13.5, 20.0), K, 1.1); - p.seg_w((14.5, 4.0), (9.5, 20.0), K, 1.1); - } - I::Underline => { - let mut pts = vec![(6.5, 3.0)]; - pts.extend(arc_pts((12.0, 11.5), 5.5, 5.5, 180.0, 0.0)); - pts.push((17.5, 3.0)); - p.line_w(&pts, K, 1.1); - p.seg_w((4.5, 21.0), (19.5, 21.0), B, 1.2); - } - I::Strikethrough => { - p.text("ab", (12.0, 11.0), 19.0, K); - p.seg_w((2.5, 13.0), (21.5, 13.0), R, 1.1); - } - I::Superscript => { - p.seg((3.0, 9.0), (13.0, 21.0), K); - p.seg((13.0, 9.0), (3.0, 21.0), K); - p.digit(2, 16.0, 2.5, 6.5, B); - } - I::Subscript => { - p.seg((3.0, 3.5), (13.0, 15.5), K); - p.seg((13.0, 3.5), (3.0, 15.5), K); - p.digit(2, 16.0, 15.0, 6.5, B); - } - I::CharSpacing => { - p.letter_a(2.0, 3.0, 10.5, 15.0, K, 1.0); - p.line(&[(13.5, 3.0), (17.75, 15.0), (22.0, 3.0)], K); - p.arrow2((2.5, 20.0), (21.5, 20.0), B); - } - I::ChangeCase => { - p.text("A", (8.0, 11.0), 19.0, K); - p.text("a", (17.5, 12.5), 16.0, B); - p.seg((3.0, 21.0), (21.0, 21.0), K.a(0.4)); - } - I::Highlight => { - let m = |pts: &[P2]| tf((8.0, 15.0), -45.0, pts); - p.poly(&m(&[(0.0, -1.2), (3.0, -3.0), (3.0, 3.0), (0.0, 1.4)]), Y, K); - p.poly(&m(&[(3.0, -3.0), (13.5, -3.0), (13.5, 3.0), (3.0, 3.0)]), N, K); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, Y, N); - } - I::FontColor => { - p.letter_a(4.5, 2.5, 19.5, 17.0, K, 1.1); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, R, N); - } - I::TextShadow => { - p.text("S", (14.0, 13.8), 22.0, K.a(0.3)); - p.text_bold("S", (11.0, 11.0), 22.0, K); - } - - // ----- Paragraph ----- - I::Bullets => { - for y in [5.5, 12.0, 18.5] { - p.dot((4.5, y), 1.8, B); - p.seg((9.0, y), (21.0, y), K); - } - } - I::Numbering => { - for (i, y) in [5.5_f32, 12.0, 18.5].into_iter().enumerate() { - p.digit(i as u8 + 1, 2.8, y - 2.6, 5.2, B); - p.seg((9.5, y), (21.0, y), K); - } - } - I::DecreaseIndent | I::IncreaseIndent => { - p.seg((3.0, 4.0), (21.0, 4.0), K); - p.seg((11.0, 9.5), (21.0, 9.5), K); - p.seg((11.0, 14.5), (21.0, 14.5), K); - p.seg((3.0, 20.0), (21.0, 20.0), K); - if icon == I::DecreaseIndent { - p.poly(&[(8.0, 8.0), (8.0, 16.0), (3.0, 12.0)], B, N); - } else { - p.poly(&[(3.0, 8.0), (3.0, 16.0), (8.0, 12.0)], B, N); - } - } - I::LineSpacing => { - for y in [5.0, 12.0, 19.0] { - p.seg((11.0, y), (21.0, y), K); - } - p.arrow2((5.0, 2.5), (5.0, 21.5), B); - } - I::Columns => { - for y in [4.5, 9.5, 14.5, 19.5] { - p.seg((2.5, y), (10.5, y), K); - p.seg((13.5, y), (21.5, y), K); - } - } - I::AlignLeft | I::AlignCenter | I::AlignRight | I::Justify => { - let rows: [(f32, f32); 4] = match icon { - I::AlignLeft => [(3.0, 21.0), (3.0, 15.0), (3.0, 21.0), (3.0, 13.0)], - I::AlignCenter => [(3.0, 21.0), (7.0, 17.0), (3.0, 21.0), (8.0, 16.0)], - I::AlignRight => [(3.0, 21.0), (9.0, 21.0), (3.0, 21.0), (11.0, 21.0)], - _ => [(3.0, 21.0), (3.0, 21.0), (3.0, 21.0), (3.0, 14.0)], - }; - for ((a, b), y) in rows.into_iter().zip([4.5, 9.5, 14.5, 19.5]) { - p.seg((a, y), (b, y), K); - } - } - I::TextDirection => { - // A capital A lying on its side. - p.line_w(&[(17.0, 5.5), (3.0, 12.0), (17.0, 18.5)], K, 1.1); - p.seg_w((11.7, 8.3), (11.7, 15.7), K, 1.1); - p.arrow((21.0, 3.5), (21.0, 20.5), B); - } - I::AlignText => { - p.rect(2.5, 3.0, 15.0, 21.0, 1.2, N, K); - p.seg((5.5, 10.0), (12.0, 10.0), K); - p.seg((5.5, 14.0), (10.5, 14.0), K); - p.arrow((19.5, 2.5), (19.5, 9.5), B); - p.arrow((19.5, 21.5), (19.5, 14.5), B); - } - I::ConvertSmartArt => { - for (y, x1) in [(4.0, 11.0), (8.0, 9.0), (12.0, 11.0)] { - p.seg((2.5, y), (x1, y), K); - } - p.path_arrow(&[(14.0, 6.0), (19.0, 6.0), (19.0, 12.5)], B); - p.rect(2.5, 15.5, 7.5, 21.5, 1.0, B, N); - p.rect(9.5, 15.5, 14.5, 21.5, 1.0, O, N); - p.rect(16.5, 15.5, 21.5, 21.5, 1.0, G, N); - } - - // ----- Drawing ----- - I::Shapes => { - p.rect(11.0, 2.5, 21.5, 13.0, 1.0, t(G), G); - p.rshape(&[(15.25, 11.0), (21.5, 21.5), (9.0, 21.5)], 0.8, t(O), O); - p.circle((8.0, 9.0), 5.5, t(B), B); - } - I::Arrange => { - p.rect(2.5, 2.5, 11.5, 11.5, 1.0, N, K); - p.rect(7.5, 7.5, 16.5, 16.5, 1.0, t(B), B); - p.rect(12.5, 12.5, 21.5, 21.5, 1.0, B, B); - } - I::QuickStyles => { - p.rect(3.0, 3.0, 11.0, 11.0, 1.5, B, N); - p.rect(13.0, 3.0, 21.0, 11.0, 1.5, N, O); - p.rect(3.0, 13.0, 11.0, 21.0, 1.5, t(G), G); - p.rect(13.0, 13.0, 21.0, 21.0, 1.5, PU, N); - } - I::ShapeFill => { - let m = |pts: &[P2]| tf((10.0, 10.0), 45.0, pts); - p.poly(&m(&[(-5.0, 0.0), (5.0, 0.0), (5.0, 5.0), (-5.0, 5.0)]), B, N); - p.rshape(&m(&[(-5.0, -5.0), (5.0, -5.0), (5.0, 5.0), (-5.0, 5.0)]), 0.8, N, K); - p.line(&quad_pts((5.2, 6.4), (5.5, 1.0), (10.0, 3.0), 10), K); - p.poly(&[(18.5, 8.5), (20.6, 12.6), (16.4, 12.6)], B, N); - p.dot((18.5, 13.4), 2.1, B); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); - } - I::ShapeOutline => { - p.tool((4.5, 15.5), -45.0, 17.0, 2.4, 4.2, N, K, 0.0, N); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); - } - I::ShapeEffects => { - p.rect(6.0, 8.0, 18.5, 20.5, 2.0, K.a(0.25), N); - p.rect(3.0, 5.0, 15.5, 17.5, 2.0, B, N); - p.sparkle((19.0, 5.0), 4.0, Y); - } - I::TextFill => { - p.letter_a(4.5, 2.5, 19.5, 17.0, B, 1.5); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); - } - I::TextOutline => { - let outer = [(3.5, 17.0), (9.6, 2.5), (14.4, 2.5), (20.5, 17.0), (16.6, 17.0), (15.2, 13.2), (8.8, 13.2), (7.4, 17.0)]; - p.shape_w(&outer, N, K, 0.8); - p.poly_w(&[(10.0, 10.0), (12.0, 5.0), (14.0, 10.0)], N, K, 0.8); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); - } - I::TextEffects => { - p.letter_a(3.0, 5.0, 17.0, 21.0, PU, 1.3); - p.sparkle((19.0, 5.0), 4.5, Y); - } - I::EditShape => { - let a = (4.0, 19.0); - let b = (11.0, 4.5); - let c = (20.0, 17.5); - let mut pts = vec![a, b]; - pts.extend(cubic_pts(b, (17.0, 4.0), (23.0, 11.0), c, 16)); - pts.extend(cubic_pts(c, (15.0, 22.0), (9.0, 17.0), a, 16)); - p.shape(&pts, t(B), K); - for q in [a, b, c] { - p.rect(q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6, 0.3, B, N); - } - } - I::MergeShapes => { - let mut lens = arc_pts((9.0, 12.0), 6.5, 6.5, -62.5, 62.5); - lens.extend(arc_pts((15.0, 12.0), 6.5, 6.5, 117.5, 242.5)); - p.poly(&lens, PU, N); - p.circle((9.0, 12.0), 6.5, N, K); - p.circle((15.0, 12.0), 6.5, N, K); - } - - // ----- Insert ----- - I::Table => p.table(2.5, 4.0, 21.5, 20.0, 3, 3, B), - I::Picture => p.picture(2.0, 4.0, 22.0, 20.0), - I::Screenshot => { - for (cx, cy, sx, sy) in [(2.5, 3.5, 1.0, 1.0), (21.5, 3.5, -1.0, 1.0), (2.5, 20.5, 1.0, -1.0), (21.5, 20.5, -1.0, -1.0)] { - p.rline(&[(cx, cy + 4.0 * sy), (cx, cy), (cx + 4.0 * sx, cy)], 1.0, K); - } - p.rect(6.5, 7.5, 17.5, 16.5, 1.0, t(B), B); - p.seg((6.5, 10.0), (17.5, 10.0), B); - } - I::Icons => { - p.person(6.75, 2.5, 0.75, B); - p.star((17.0, 7.0), 5.0, Y, N); - let heart: Vec = (0..40) - .map(|i| { - let tt = i as f32 / 40.0 * 2.0 * PI; - let x = 16.0 * tt.sin().powi(3); - let y = 13.0 * tt.cos() - 5.0 * (2.0 * tt).cos() - 2.0 * (3.0 * tt).cos() - (4.0 * tt).cos(); - (7.0 + x * 0.29, 17.0 - y * 0.29) - }) - .collect(); - p.shape(&heart, R, N); - p.rshape(&[(17.0, 12.5), (21.5, 17.0), (17.0, 21.5), (12.5, 17.0)], 0.8, G, N); - } - I::Models3d => { - let (top, ur, lr, bot, ll, ul, mid) = ((12.0, 2.5), (20.5, 7.2), (20.5, 16.8), (12.0, 21.5), (3.5, 16.8), (3.5, 7.2), (12.0, 11.9)); - p.poly(&[top, ur, mid, ul], t(B), N); - p.poly(&[ul, mid, bot, ll], B.a(0.55), N); - p.poly(&[mid, ur, lr, bot], B, N); - p.shape_w(&[top, ur, lr, bot, ll, ul], N, K, 1.0); - p.line(&[ul, mid, ur], K); - p.seg(mid, bot, K); - } - I::SmartArt => { - p.rect(8.5, 2.5, 15.5, 8.5, 1.0, G, N); - p.seg((12.0, 8.5), (12.0, 11.5), K); - p.rline(&[(5.5, 14.5), (5.5, 11.5), (18.5, 11.5), (18.5, 14.5)], 1.0, K); - p.rect(2.0, 14.5, 9.0, 20.5, 1.0, B, N); - p.rect(15.0, 14.5, 22.0, 20.5, 1.0, O, N); - } - I::Chart => p.bars(2.5, 4.0, 21.5, 20.5), - I::Zoom => { - p.rline(&[(10.5, 15.0), (2.0, 15.0), (2.0, 3.0), (18.0, 3.0), (18.0, 9.5)], 1.5, K); - p.seg((5.0, 6.5), (11.0, 6.5), K.a(0.5)); - p.magnifier((14.5, 14.5), 4.5, t(B), B); - } - I::Link => { - let link = |c: P2, col: C| { - let local = rrect_pts(-5.5, -2.8, 5.5, 2.8, 2.8); - p.shape(&tf(c, -45.0, &local), N, col); - }; - link((8.5, 15.5), K); - link((15.5, 8.5), B); - } - I::Action => { - p.rect(2.5, 3.5, 18.0, 12.5, 2.0, t(B), B); - p.cursor(11.5, 8.5, 0.82); - } - I::Comment => { - p.bubble(2.5, 3.5, 21.5, 16.5, t(Y), K); - p.seg((6.5, 8.0), (17.5, 8.0), K); - p.seg((6.5, 12.0), (14.0, 12.0), K); - } - I::TextBox => { - p.rect(3.5, 4.5, 20.5, 19.5, 0.5, N, K); - for (x, y) in [(3.5, 4.5), (20.5, 4.5), (3.5, 19.5), (20.5, 19.5)] { - p.rect(x - 1.5, y - 1.5, x + 1.5, y + 1.5, 0.3, B, N); - } - p.seg((7.5, 9.5), (16.5, 9.5), K); - p.seg((7.5, 14.5), (13.5, 14.5), K); - } - I::HeaderFooter => { - p.rect(4.0, 2.5, 20.0, 21.5, 1.2, N, K); - p.rect(6.5, 5.0, 17.5, 7.5, 0.6, B, N); - p.rect(6.5, 16.5, 17.5, 19.0, 0.6, B, N); - p.seg_w((7.0, 10.5), (17.0, 10.5), K.a(0.45), 0.8); - p.seg_w((7.0, 13.2), (14.5, 13.2), K.a(0.45), 0.8); - } - I::WordArt => { - p.text_rot("A", (13.5, 13.5), 22.0, PU, -10.0); - p.text_rot("A", (11.5, 11.5), 22.0, O, -10.0); - p.text_rot("A", (11.3, 11.5), 22.0, O, -10.0); - } - I::DateTime => { - p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); - p.rect(3.0, 5.0, 21.0, 9.5, 1.6, R, N); - p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); - p.seg((8.0, 2.8), (8.0, 6.5), K); - p.seg((16.0, 2.8), (16.0, 6.5), K); - for (i, (x, y)) in [(8.0, 13.5), (12.0, 13.5), (16.0, 13.5), (8.0, 17.5), (12.0, 17.5), (16.0, 17.5)].into_iter().enumerate() { - p.dot((x, y), 1.1, if i == 4 { B } else { K }); - } - } - I::SlideNumber => { - p.frame(2.0, 4.0, 22.0, 20.0); - p.seg((10.7, 7.5), (9.7, 16.5), B); - p.seg((15.0, 7.5), (14.0, 16.5), B); - p.seg((7.5, 10.5), (17.0, 10.5), B); - p.seg((7.0, 13.6), (16.5, 13.6), B); - } - I::Object => { - p.page(3.0, 2.0, 16.0, 19.0, N, K); - p.rect(11.5, 11.5, 21.5, 21.5, 1.6, B, N); - p.rect(14.2, 14.2, 18.8, 18.8, 0.6, N, W); - } - I::Equation => { - p.line_w(&[(18.5, 6.5), (18.0, 4.0), (6.0, 4.0), (12.5, 12.0), (6.0, 20.0), (18.0, 20.0), (18.5, 17.5)], B, 1.15); - } - I::Symbol => p.text("Ω", (12.0, 12.0), 22.0, K), - I::Video => { - p.rect(2.0, 6.0, 15.5, 18.0, 2.0, t(B), K); - p.rshape(&[(16.5, 10.5), (22.0, 7.0), (22.0, 17.0), (16.5, 13.5)], 0.6, N, K); - p.dot((6.0, 10.0), 1.2, R); - } - I::Audio => { - p.speaker(2.5, 12.0, t(PU), K); - p.arc((11.0, 12.0), 4.5, -45.0, 45.0, PU); - p.arc((11.0, 12.0), 8.5, -45.0, 45.0, PU); - } - I::ScreenRecording => { - p.monitor(2.0, 3.0, 22.0, 16.5); - p.circle((12.0, 9.75), 3.0, R, N); - } - I::Cameo => { - p.frame(2.0, 4.0, 22.0, 20.0); - p.circle((12.0, 12.0), 5.6, t(B), B); - p.dot((12.0, 10.4), 1.9, B); - p.poly(&arc_pts((12.0, 16.0), 3.4, 2.6, 180.0, 360.0), B, N); - } - - // ----- Draw tab ----- - I::Pen => { - p.tool((3.5, 20.5), -45.0, 23.0, 2.2, 5.0, B, K, 3.5, K); - } - I::Pencil => { - p.tool((3.5, 20.5), -45.0, 22.0, 2.6, 5.0, Y, K, 3.5, R); - } - I::HighlighterPen => { - let m = |pts: &[P2]| tf((6.0, 15.0), -45.0, pts); - p.poly(&m(&[(0.0, -1.0), (3.0, -3.2), (3.0, 3.2), (0.0, 2.2)]), Y, K); - p.poly(&m(&[(3.0, -3.6), (14.5, -3.6), (14.5, 3.6), (3.0, 3.6)]), t(Y), K); - p.rect(2.0, 19.5, 13.0, 22.5, 0.8, Y, N); - } - I::Eraser => { - let m = |pts: &[P2]| tf((12.5, 11.0), -45.0, pts); - p.poly(&m(&[(-8.0, -4.0), (-1.0, -4.0), (-1.0, 4.0), (-8.0, 4.0)]), R, N); - p.rshape(&m(&[(-8.0, -4.0), (8.0, -4.0), (8.0, 4.0), (-8.0, 4.0)]), 1.0, N, K); - p.seg((12.0, 21.0), (21.0, 21.0), K); - } - I::LassoSelect => { - let mut e = arc_pts((13.0, 9.0), 8.5, 6.0, 120.0, 480.0); - e.truncate(e.len().saturating_sub(1)); - p.dashed(&e, B, 2.6, 1.7); - p.curve((8.8, 14.2), (5.5, 16.5), (10.0, 19.0), (5.5, 22.0), K); - p.dot((8.8, 14.2), 1.4, K); - } - I::InkToShape => { - p.scribble(1.5, 2.0, 12.5, 11.0, K); - p.circle((16.5, 16.5), 5.0, t(B), B); - } - I::InkToText => { - p.scribble(1.5, 2.0, 12.5, 11.0, K); - p.letter_a(12.0, 9.0, 21.5, 21.5, B, 1.2); - } - I::InkToMath => { - p.scribble(1.5, 2.0, 12.5, 11.0, K); - p.line_w(&[(21.0, 11.0), (12.5, 11.0), (17.0, 16.25), (12.5, 21.5), (21.0, 21.5)], B, 1.1); - } - I::Ruler => { - let m = |pts: &[P2]| tf((12.0, 12.0), -45.0, pts); - p.rshape(&m(&[(-11.5, -4.0), (11.5, -4.0), (11.5, 4.0), (-11.5, 4.0)]), 0.8, Y.a(0.85), K); - for (i, u) in [-8.0_f32, -5.0, -2.0, 1.0, 4.0, 7.0].into_iter().enumerate() { - let l = if i % 2 == 0 { 3.2 } else { 1.8 }; - p.line_w(&m(&[(u, -4.0), (u, -4.0 + l)]), D, 0.8); - } - } - - // ----- Design ----- - I::Themes => { - p.rect(9.5, 2.5, 21.5, 14.5, 2.0, O, N); - p.rect(2.5, 8.5, 15.0, 21.5, 2.0, B, N); - p.text("Aa", (8.75, 15.0), 10.0, W); - } - I::Variants => { - p.rect(8.0, 2.5, 21.5, 11.0, 1.2, t(PU), PU); - p.rect(5.0, 7.0, 18.5, 15.5, 1.2, t(G), G); - p.rect(2.5, 12.0, 16.0, 21.0, 1.2, B, B); - } - I::Colors => { - for (i, c) in [R, Y, G, B].into_iter().enumerate() { - let x = 3.0 + i as f32 * 4.5; - p.rect(x, 4.0, x + 4.5, 20.0, 0.0, c, N); - } - p.rect(3.0, 4.0, 21.0, 20.0, 0.0, N, K); - } - I::Fonts => { - p.text("A", (8.0, 11.0), 20.0, K); - p.text("g", (17.0, 10.0), 18.0, B); - } - I::BackgroundStyles => { - p.poly(&[(2.7, 4.7), (21.3, 4.7), (2.7, 19.3)], t(B), N); - p.poly(&[(21.3, 4.7), (21.3, 19.3), (2.7, 19.3)], PU.a(0.7), N); - p.frame(2.0, 4.0, 22.0, 20.0); - } - I::SlideSize => { - p.rline(&[(17.0, 12.0), (17.0, 19.0), (2.5, 19.0), (2.5, 7.0), (10.0, 7.0)], 1.5, K); - p.arrow((9.0, 13.0), (21.5, 2.5), B); - } - I::FormatBackground => { - p.rline(&[(10.0, 17.0), (2.0, 17.0), (2.0, 4.0), (20.0, 4.0), (20.0, 9.0)], 1.5, K); - p.rect(4.5, 6.5, 13.5, 12.5, 0.6, t(PU), N); - p.brush((12.0, 20.5), -45.0, 13.0, Y); - } - I::DesignIdeas => { - p.circle((12.0, 9.5), 6.0, Y.a(0.5), K); - p.seg((9.6, 15.7), (9.6, 17.0), K); - p.seg((14.4, 15.7), (14.4, 17.0), K); - p.seg((9.5, 18.0), (14.5, 18.0), K); - p.seg((10.5, 21.0), (13.5, 21.0), K); - for a in [-160.0_f32, -125.0, -55.0, -20.0] { - let (s, c) = rad(a).sin_cos(); - p.seg((12.0 + c * 8.0, 9.5 + s * 8.0), (12.0 + c * 10.0, 9.5 + s * 10.0), Y); - } - } - - // ----- Transitions / animations ----- - I::Preview => { - p.circle((12.0, 12.0), 9.5, N, K); - p.rshape(&[(9.5, 7.0), (17.0, 12.0), (9.5, 17.0)], 0.8, G, N); - } - I::EffectOptions => { - p.rect(2.5, 8.5, 15.5, 21.5, 1.5, t(B), K); - p.arrow((9.0, 15.0), (21.5, 2.5), B); - } - I::Sound => { - let head = |c: P2| { - let pts = tf(c, -22.0, &arc_pts((0.0, 0.0), 3.0, 2.1, 0.0, 351.0)); - p.poly(&pts, PU, N); - }; - head((6.3, 18.0)); - head((16.3, 15.5)); - p.seg((9.0, 17.0), (9.0, 5.0), PU); - p.seg((19.0, 14.5), (19.0, 2.5), PU); - p.poly(&[(9.0, 4.5), (19.0, 2.0), (19.0, 5.5), (9.0, 8.0)], PU, N); - } - I::Duration => { - p.circle((12.0, 13.5), 8.0, N, K); - p.rect(10.0, 2.0, 14.0, 4.0, 0.6, K, N); - p.seg((12.0, 4.0), (12.0, 5.5), K); - p.seg((12.0, 13.5), (15.8, 9.0), B); - p.dot((12.0, 13.5), 1.3, B); - } - I::ApplyToAll => { - p.rline(&[(6.0, 7.5), (6.0, 3.0), (21.5, 3.0), (21.5, 13.5), (17.5, 13.5)], 1.5, K); - p.rect(2.5, 7.5, 17.5, 18.0, 1.5, N, K); - p.badge((18.0, 18.0), 4.8, G, Glyph::Check); - } - I::AnimEntrance => { - p.star((13.5, 12.5), 8.5, G, N); - p.seg((1.5, 9.0), (4.5, 9.0), K); - p.seg((0.8, 13.5), (4.0, 13.5), K); - } - I::AnimEmphasis => { - p.star((12.0, 12.5), 9.5, Y, N); - } - I::AnimExit => { - p.star((10.5, 12.5), 8.5, R, N); - p.seg((19.5, 9.0), (22.5, 9.0), K); - p.seg((20.0, 13.5), (23.2, 13.5), K); - } - I::AnimPath => { - let pts = cubic_pts((4.5, 19.5), (3.0, 6.0), (20.0, 19.0), (19.5, 5.0), 60); - p.dotted(&pts[6..pts.len().saturating_sub(4)], K, 2.4, 0.55); - p.head((19.5, 4.0), -90.0, 4.0, K); - p.circle((4.5, 19.5), 2.4, G, N); - } - I::AnimationPane => { - p.rect(2.5, 3.0, 21.5, 21.0, 1.5, N, K); - p.seg((11.0, 3.0), (11.0, 21.0), K); - p.rect(4.5, 7.0, 8.5, 12.0, 0.5, t(B), N); - for (y, c) in [(7.5, G), (12.0, Y), (16.5, R)] { - p.seg_w((13.5, y), (19.0, y), c, 1.4); - } - } - I::Trigger => { - p.rshape(&[(14.5, 2.0), (5.0, 13.5), (11.5, 13.5), (9.5, 22.0), (19.0, 9.5), (12.5, 9.5)], 0.6, Y, K); - } - I::AnimationPainter => { - p.star((7.5, 7.5), 6.0, G, N); - p.brush((12.0, 12.0), 45.0, 13.0, Y); - } - I::AddAnimation => { - p.star((10.5, 11.0), 9.0, Y, N); - p.badge((18.5, 18.5), 4.8, G, Glyph::Plus); - } - - // ----- Slide show ----- - I::PlayFromStart => { - p.frame(2.0, 4.0, 22.0, 20.0); - p.rect(7.0, 8.0, 9.2, 16.0, 0.5, G, N); - p.rshape(&[(11.0, 8.0), (17.5, 12.0), (11.0, 16.0)], 0.6, G, N); - } - I::PlayFromCurrent => { - p.frame(2.0, 4.0, 22.0, 20.0); - p.rshape(&[(9.5, 8.0), (16.0, 12.0), (9.5, 16.0)], 0.6, G, N); - } - I::PresenterView => { - p.monitor(2.0, 3.0, 22.0, 16.5); - p.rect(4.5, 5.5, 13.0, 12.0, 0.6, t(B), B); - p.seg_w((15.0, 6.5), (19.5, 6.5), K, 0.8); - p.seg_w((15.0, 9.5), (19.5, 9.5), K, 0.8); - p.seg_w((15.0, 12.5), (18.0, 12.5), K, 0.8); - } - I::CustomShow => { - p.rect(2.5, 2.5, 12.5, 8.0, 1.0, N, K); - p.rect(2.5, 9.5, 12.5, 15.0, 1.0, O, O); - p.rect(2.5, 16.5, 12.5, 22.0, 1.0, N, K); - p.rshape(&[(15.5, 8.0), (21.5, 12.25), (15.5, 16.5)], 0.6, G, N); - } - I::SetUpShow => { - p.frame(2.0, 4.0, 19.5, 16.5); - p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.5)); - p.gear_badge((17.5, 17.5), 5.5, B); - } - I::RehearseTimings => { - p.frame(2.0, 4.0, 19.5, 16.5); - p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.5)); - p.clock_badge((17.5, 17.5), 5.2, B); - } - I::Record => { - p.circle((12.0, 12.0), 9.5, N, K); - p.circle((12.0, 12.0), 5.5, R, N); - } - I::Subtitles => { - p.rect(2.0, 4.5, 22.0, 19.5, 2.0, N, K); - p.seg((5.5, 12.0), (11.0, 12.0), B); - p.seg((13.5, 12.0), (18.5, 12.0), B); - p.seg((5.5, 16.0), (8.5, 16.0), B); - p.seg((11.0, 16.0), (18.5, 16.0), B); - } - I::Coach => { - p.person(9.0, 5.0, 1.2, t(B)); - p.circle((9.0, 8.1), 3.1, N, B); - p.line(&arc_pts((9.0, 19.4), 6.2, 6.0, 180.0, 360.0), B); - p.star((18.5, 6.0), 4.2, Y, N); - } - - // ----- Review ----- - I::Spelling => { - p.text("abc", (11.5, 7.0), 12.0, K); - p.line_w(&[(6.0, 16.0), (10.0, 20.0), (19.5, 11.0)], G, 1.3); - } - I::Thesaurus => { - p.rect(8.0, 2.5, 19.5, 21.5, 1.2, t(B), N); - p.rect(4.5, 2.5, 19.5, 21.5, 1.5, N, K); - p.seg((8.0, 2.5), (8.0, 21.5), K); - p.poly(&[(13.5, 2.5), (17.0, 2.5), (17.0, 9.0), (15.25, 7.5), (13.5, 9.0)], R, N); - } - I::Accessibility => { - p.circle((12.0, 12.0), 9.8, N, B); - p.dot((12.0, 6.5), 1.6, B); - p.seg((6.8, 9.6), (17.2, 9.6), B); - p.seg((12.0, 9.6), (12.0, 13.8), B); - p.line(&[(9.0, 18.5), (12.0, 13.8), (15.0, 18.5)], B); - } - I::Translate => { - p.rect(10.0, 9.0, 21.5, 20.5, 2.0, O, N); - p.rect(2.5, 3.5, 14.0, 15.0, 2.0, B, N); - p.letter_a(5.5, 5.8, 11.0, 12.8, W, 0.9); - p.seg_w((17.75, 10.8), (17.75, 11.9), W, 0.8); - p.seg_w((15.2, 12.9), (20.3, 12.9), W, 0.8); - p.seg_w((15.6, 14.4), (19.9, 18.9), W, 0.8); - p.seg_w((19.9, 14.4), (15.6, 18.9), W, 0.8); - } - I::Language => { - p.circle((12.0, 12.0), 9.5, N, B); - p.ellipse((12.0, 12.0), 4.2, 9.5, N, B); - p.seg_w((2.5, 12.0), (21.5, 12.0), B, 0.8); - p.seg_w((4.3, 7.0), (19.7, 7.0), B, 0.8); - p.seg_w((4.3, 17.0), (19.7, 17.0), B, 0.8); - } - I::NewComment => { - p.bubble(2.0, 3.0, 19.5, 15.0, N, K); - p.badge((18.0, 17.5), 5.0, G, Glyph::Plus); - } - I::DeleteComment => { - p.bubble(2.0, 3.0, 19.5, 15.0, N, K); - p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); - } - I::PrevComment => { - p.bubble(2.5, 3.0, 21.5, 17.0, N, K); - p.arrow((16.5, 10.0), (7.0, 10.0), B); - } - I::NextComment => { - p.bubble(2.5, 3.0, 21.5, 17.0, N, K); - p.arrow((7.5, 10.0), (17.0, 10.0), B); - } - I::ShowComments => { - p.rline(&[(8.0, 7.5), (8.0, 2.5), (21.5, 2.5), (21.5, 13.0), (17.0, 13.0)], 2.0, K); - p.bubble(2.5, 7.5, 16.5, 17.5, t(Y), K); - p.seg((5.5, 11.0), (13.5, 11.0), K); - p.seg((5.5, 14.2), (11.0, 14.2), K); - } - I::Compare => { - p.rect(2.5, 2.5, 11.0, 15.5, 1.2, N, K); - p.rect(13.0, 8.5, 21.5, 21.5, 1.2, t(B), B); - p.arrow((13.0, 4.0), (21.5, 4.0), K); - p.arrow((11.0, 20.0), (2.5, 20.0), K); - } - I::ReadOnly => { - p.page(3.0, 2.0, 16.0, 20.5, N, K); - p.lock(13.0, 15.0, 21.5, 22.0); - } - - // ----- View ----- - I::ViewNormal => { - p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); - p.seg((8.0, 3.5), (8.0, 20.5), K); - for y in [6.0, 10.5, 15.0] { - p.rect(3.9, y, 6.2, y + 2.6, 0.3, K.a(0.55), N); - } - p.rect(10.0, 6.0, 20.0, 15.0, 0.6, t(B), B); - p.seg_w((10.0, 18.0), (17.0, 18.0), K.a(0.5), 0.8); - } - I::ViewOutline => { - p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); - p.seg((11.0, 3.5), (11.0, 20.5), K); - p.seg_w((4.0, 7.0), (9.0, 7.0), K, 0.8); - p.seg_w((5.5, 10.0), (9.0, 10.0), K, 0.8); - p.seg_w((5.5, 13.0), (9.0, 13.0), K, 0.8); - p.seg_w((4.0, 16.0), (9.0, 16.0), K, 0.8); - p.rect(13.0, 7.0, 20.0, 13.0, 0.6, t(B), B); - } - I::ViewSorter => { - for (i, (x0, x1)) in [(2.5, 11.0), (13.0, 21.5)].into_iter().enumerate() { - for (j, (y0, y1)) in [(4.0, 10.5), (13.5, 20.0)].into_iter().enumerate() { - if i == 0 && j == 0 { - p.rect(x0, y0, x1, y1, 0.8, t(B), B); - } else { - p.rect(x0, y0, x1, y1, 0.8, N, K); - } - } - } - } - I::ViewNotesPage => { - p.rect(5.0, 2.0, 19.0, 22.0, 1.2, N, K); - p.rect(7.5, 4.5, 16.5, 10.5, 0.6, t(B), B); - p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.8); - p.seg_w((7.5, 17.5), (14.0, 17.5), K, 0.8); - } - I::ViewReading => { - p.book(t(B), K); - p.seg((12.0, 7.0), (12.0, 20.5), K); - } - I::SlideMaster => { - p.rect(2.0, 2.5, 15.0, 10.5, 1.2, t(PU), PU); - p.seg((4.5, 5.5), (11.0, 5.5), PU); - p.rline(&[(5.0, 10.5), (5.0, 19.5), (9.0, 19.5)], 1.0, K); - p.seg((5.0, 14.0), (9.0, 14.0), K); - p.rect(9.0, 12.0, 17.0, 16.0, 0.8, N, K); - p.rect(9.0, 17.5, 17.0, 21.5, 0.8, N, K); - } - I::HandoutMaster => { - p.rect(4.0, 2.0, 20.0, 22.0, 1.2, N, K); - for (x, y) in [(6.5, 4.5), (12.75, 4.5), (6.5, 11.0), (12.75, 11.0)] { - p.rect(x, y, x + 4.75, y + 4.5, 0.4, t(PU), PU); - } - p.seg_w((6.5, 19.0), (17.5, 19.0), K, 0.8); - } - I::NotesMaster => { - p.rline(&[(8.0, 4.0), (8.0, 2.0), (21.0, 2.0), (21.0, 19.0), (16.0, 19.0)], 1.2, PU); - p.rect(3.0, 4.5, 16.0, 22.0, 1.2, N, K); - p.rect(5.5, 7.0, 13.5, 12.0, 0.5, t(PU), PU); - p.seg_w((5.5, 15.5), (13.5, 15.5), K, 0.8); - p.seg_w((5.5, 18.8), (11.5, 18.8), K, 0.8); - } - I::Gridlines => { - for v in [9.0, 15.0] { - p.seg_w((v, 3.0), (v, 21.0), K.a(0.6), 0.7); - p.seg_w((3.0, v), (21.0, v), K.a(0.6), 0.7); - } - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); - } - I::Guides => { - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); - p.dashed(&[(12.0, 1.5), (12.0, 22.5)], B, 2.2, 1.6); - p.dashed(&[(1.5, 12.0), (22.5, 12.0)], B, 2.2, 1.6); - } - I::Notes => { - p.rect(4.0, 4.0, 20.0, 21.5, 1.5, t(Y), K); - for x in [8.0, 12.0, 16.0] { - p.seg((x, 2.3), (x, 5.8), K); - } - p.seg_w((7.5, 10.5), (16.5, 10.5), K, 0.85); - p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.85); - p.seg_w((7.5, 17.5), (13.5, 17.5), K, 0.85); - } - I::ZoomGlass => p.magnifier((10.0, 10.0), 7.0, t(B), K), - I::FitToWindow => { - for (cx, cy, sx, sy) in [(2.5, 2.5, 1.0, 1.0), (21.5, 2.5, -1.0, 1.0), (2.5, 21.5, 1.0, -1.0), (21.5, 21.5, -1.0, -1.0)] { - p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); - } - p.rect(7.0, 8.0, 17.0, 16.0, 1.0, t(B), B); - } - I::Grayscale => { - p.rect(3.0, 3.0, 9.0, 21.0, 0.0, K.a(0.85), N); - p.rect(9.0, 3.0, 15.0, 21.0, 0.0, K.a(0.45), N); - p.rect(15.0, 3.0, 21.0, 21.0, 0.0, K.a(0.15), N); - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); - } - I::NewWindow => { - p.rect(2.0, 3.5, 20.0, 17.0, 1.5, N, K); - p.seg((2.0, 7.0), (20.0, 7.0), K); - p.dot((4.5, 5.25), 0.6, K); - p.badge((18.5, 18.0), 4.8, G, Glyph::Plus); - } - I::Macros => { - p.page(3.0, 2.0, 17.0, 21.0, N, K); - p.line(&[(8.5, 9.0), (6.0, 11.5), (8.5, 14.0)], B); - p.line(&[(11.5, 9.0), (14.0, 11.5), (11.5, 14.0)], B); - p.gear_badge((17.5, 17.5), 5.2, O); - } - - // ----- Arrange ----- - I::BringForward => { - p.layer(10.0, 15.5, 8.0, 4.0, N, K); - p.layer(10.0, 8.5, 8.0, 4.0, B, B); - p.arrow((21.0, 13.0), (21.0, 3.0), K); - } - I::SendBackward => { - p.layer(10.0, 15.5, 8.0, 4.0, B, B); - p.layer(10.0, 8.5, 8.0, 4.0, N, K); - p.arrow((21.0, 11.0), (21.0, 21.0), K); - } - I::BringToFront => { - p.layer(10.0, 18.5, 7.5, 3.2, N, K); - p.layer(10.0, 12.0, 7.5, 3.2, N, K); - p.layer(10.0, 5.5, 7.5, 3.2, B, B); - p.arrow((20.5, 14.0), (20.5, 4.5), K); - p.seg((18.0, 2.5), (23.0, 2.5), K); - } - I::SendToBack => { - p.layer(10.0, 18.5, 7.5, 3.2, B, B); - p.layer(10.0, 12.0, 7.5, 3.2, N, K); - p.layer(10.0, 5.5, 7.5, 3.2, N, K); - p.arrow((20.5, 10.0), (20.5, 19.5), K); - p.seg((18.0, 21.5), (23.0, 21.5), K); - } - I::Group => { - p.dashed(&[(2.5, 2.5), (21.5, 2.5), (21.5, 21.5), (2.5, 21.5), (2.5, 2.5)], B, 2.0, 1.6); - p.rect(5.5, 5.5, 13.0, 13.0, 0.8, t(G), G); - p.circle((15.5, 15.5), 4.3, t(O), O); - } - I::Ungroup => { - p.dashed_w(&[(2.0, 2.0), (12.0, 2.0), (12.0, 12.0), (2.0, 12.0), (2.0, 2.0)], B, 1.6, 1.3, 0.8); - p.rect(4.5, 4.5, 9.5, 9.5, 0.6, t(G), G); - p.dashed_w(&[(12.0, 12.0), (22.0, 12.0), (22.0, 22.0), (12.0, 22.0), (12.0, 12.0)], B, 1.6, 1.3, 0.8); - p.circle((17.0, 17.0), 2.9, t(O), O); - } - I::AlignObjects => { - p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 2.0, 1.5); - p.rect(3.5, 4.0, 8.0, 20.0, 0.8, B, N); - p.rect(10.0, 7.5, 14.0, 16.5, 0.8, O, N); - p.rect(16.0, 5.5, 20.5, 18.5, 0.8, G, N); - } - I::AlignObjLeft => { - p.rect(3.5, 5.0, 19.5, 10.5, 0.8, t(B), B); - p.rect(3.5, 13.5, 13.5, 19.0, 0.8, t(B), B); - p.seg((3.5, 2.5), (3.5, 21.5), K); - } - I::AlignObjCenter => { - p.rect(4.0, 5.0, 20.0, 10.5, 0.8, t(B), B); - p.rect(7.0, 13.5, 17.0, 19.0, 0.8, t(B), B); - p.seg((12.0, 2.5), (12.0, 21.5), K); - } - I::AlignObjRight => { - p.rect(4.5, 5.0, 20.5, 10.5, 0.8, t(B), B); - p.rect(10.5, 13.5, 20.5, 19.0, 0.8, t(B), B); - p.seg((20.5, 2.5), (20.5, 21.5), K); - } - I::AlignObjTop => { - p.rect(5.0, 3.5, 10.5, 19.5, 0.8, t(B), B); - p.rect(13.5, 3.5, 19.0, 13.5, 0.8, t(B), B); - p.seg((2.5, 3.5), (21.5, 3.5), K); - } - I::AlignObjMiddle => { - p.rect(5.0, 4.0, 10.5, 20.0, 0.8, t(B), B); - p.rect(13.5, 7.0, 19.0, 17.0, 0.8, t(B), B); - p.seg((2.5, 12.0), (21.5, 12.0), K); - } - I::AlignObjBottom => { - p.rect(5.0, 4.5, 10.5, 20.5, 0.8, t(B), B); - p.rect(13.5, 10.5, 19.0, 20.5, 0.8, t(B), B); - p.seg((2.5, 20.5), (21.5, 20.5), K); - } - I::DistributeH => { - p.seg((2.5, 3.0), (2.5, 21.0), K); - p.seg((21.5, 3.0), (21.5, 21.0), K); - p.rect(4.9, 7.0, 8.2, 17.0, 0.6, t(B), B); - p.rect(10.35, 5.0, 13.65, 19.0, 0.6, t(B), B); - p.rect(15.8, 8.5, 19.1, 15.5, 0.6, t(B), B); - } - I::DistributeV => { - p.seg((3.0, 2.5), (21.0, 2.5), K); - p.seg((3.0, 21.5), (21.0, 21.5), K); - p.rect(7.0, 4.9, 17.0, 8.2, 0.6, t(B), B); - p.rect(5.0, 10.35, 19.0, 13.65, 0.6, t(B), B); - p.rect(8.5, 15.8, 15.5, 19.1, 0.6, t(B), B); - } - I::RotateLeft => { - p.poly(&[(3.0, 21.5), (3.0, 13.0), (12.0, 21.5)], t(B), B); - p.arc_arrow((13.0, 12.0), 7.5, 30.0, -160.0, K); - } - I::RotateRight => { - p.poly(&[(21.0, 21.5), (21.0, 13.0), (12.0, 21.5)], t(B), B); - p.arc_arrow((11.0, 12.0), 7.5, 150.0, 340.0, K); - } - I::FlipH => { - p.dashed(&[(12.0, 2.0), (12.0, 22.0)], K, 1.8, 1.4); - p.poly(&[(9.5, 5.0), (9.5, 19.0), (2.5, 19.0)], N, K); - p.poly(&[(14.5, 5.0), (21.5, 19.0), (14.5, 19.0)], B, B); - } - I::FlipV => { - p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 1.8, 1.4); - p.poly(&[(5.0, 9.5), (19.0, 9.5), (19.0, 2.5)], N, K); - p.poly(&[(5.0, 14.5), (19.0, 21.5), (19.0, 14.5)], B, B); - } - I::SelectionPane => { - p.rect(3.0, 2.5, 21.0, 21.5, 1.5, N, K); - p.rect(4.6, 4.6, 19.4, 9.4, 0.8, t(B), N); - for (y, c) in [(7.0, B), (12.0, O), (17.0, G)] { - p.rect(6.0, y - 1.3, 8.6, y + 1.3, 0.4, c, N); - p.seg_w((10.5, y), (17.5, y), K, 0.85); - } - } - I::Rotate => { - p.rect(9.0, 9.0, 15.0, 15.0, 0.8, t(B), B); - p.arc_arrow((12.0, 12.0), 8.5, -70.0, 220.0, K); - } - - // ----- Picture ----- - I::Crop => { - p.rline_w(&[(6.5, 2.0), (6.5, 17.5), (22.0, 17.5)], 0.6, K, 1.25); - p.rline_w(&[(2.0, 6.5), (17.5, 6.5), (17.5, 22.0)], 0.6, B, 1.25); - } - I::Corrections => { - p.poly(&arc_pts((12.0, 12.0), 5.2, 5.2, -90.0, 90.0), Y, N); - p.circle((12.0, 12.0), 5.2, N, K); - for i in 0..8 { - let (s, c) = rad(i as f32 * 45.0).sin_cos(); - p.seg((12.0 + c * 7.6, 12.0 + s * 7.6), (12.0 + c * 9.8, 12.0 + s * 9.8), Y); - } - } - I::ColorAdjust => { - p.circle((12.0, 8.0), 5.6, R.a(0.6), N); - p.circle((8.3, 14.6), 5.6, G.a(0.6), N); - p.circle((15.7, 14.6), 5.6, B.a(0.6), N); - } - I::ArtisticEffects => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); - p.curve_w((5.5, 16.0), (8.0, 4.5), (14.0, 21.0), (18.5, 8.0), PU, 2.0); - } - I::Transparency => { - let cs = 13.0 / 4.0; - for i in 0..4 { - for j in 0..4 { - if (i + j) % 2 == 0 { - let (x, y) = (3.0 + i as f32 * cs, 3.0 + j as f32 * cs); - p.rect(x, y, x + cs, y + cs, 0.0, K.a(0.3), N); - } - } - } - p.rect(3.0, 3.0, 16.0, 16.0, 0.6, N, K.a(0.6)); - p.rect(9.0, 9.0, 21.0, 21.0, 1.2, B.a(0.55), B); - } - I::CompressPictures => { - p.picture(6.5, 6.0, 17.5, 18.0); - p.arrow((0.8, 12.0), (5.0, 12.0), K); - p.arrow((23.2, 12.0), (19.0, 12.0), K); - } - I::ChangePicture => { - p.picture(2.0, 3.0, 18.0, 15.5); - let c = (17.5, 17.5); - if p.dis { - p.circle(c, 5.0, N, G); - } else { - p.circle(c, 5.0, G, N); - } - let wc = if p.dis { K } else { W }; - p.arc_arrow(c, 2.8, 200.0, 330.0, wc); - p.arc_arrow(c, 2.8, 20.0, 150.0, wc); - } - I::ResetPicture => { - p.picture(2.0, 3.0, 18.0, 15.5); - p.arc_arrow((17.0, 17.5), 4.0, -10.0, -260.0, B); - } - I::RemoveBackground => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, PU.a(0.55), N); - p.dot((12.0, 10.0), 3.0, B); - p.poly(&arc_pts((12.0, 19.3), 6.0, 5.5, 180.0, 360.0), B, N); - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); - } - I::AltText => { - p.picture(2.0, 2.0, 15.5, 12.5); - p.seg((2.5, 16.5), (21.5, 16.5), K); - p.seg((2.5, 20.5), (15.0, 20.5), K); - } - - // ----- Table ----- - I::InsertAbove | I::InsertBelow | I::InsertLeft | I::InsertRight => { - let (grid, add): ((f32, f32, f32, f32, usize, usize), (f32, f32, f32, f32)) = match icon { - I::InsertAbove => ((3.0, 10.0, 21.0, 21.0, 2, 3), (3.0, 2.5, 21.0, 7.5)), - I::InsertBelow => ((3.0, 3.0, 21.0, 14.0, 2, 3), (3.0, 16.5, 21.0, 21.5)), - I::InsertLeft => ((10.0, 3.0, 21.0, 21.0, 3, 2), (2.5, 3.0, 7.5, 21.0)), - _ => ((3.0, 3.0, 14.0, 21.0, 3, 2), (16.5, 3.0, 21.5, 21.0)), - }; - p.table(grid.0, grid.1, grid.2, grid.3, grid.4, grid.5, N); - p.rect(add.0, add.1, add.2, add.3, 1.0, t(G), G); - let c = ((add.0 + add.2) * 0.5, (add.1 + add.3) * 0.5); - p.seg_w((c.0 - 1.6, c.1), (c.0 + 1.6, c.1), G, 0.9); - p.seg_w((c.0, c.1 - 1.6), (c.0, c.1 + 1.6), G, 0.9); - } - I::DeleteTable => { - p.table(2.5, 3.0, 19.5, 17.0, 3, 3, B); - p.badge((18.0, 18.0), 5.0, R, Glyph::Cross); - } - I::MergeCells => { - p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); - p.dashed(&[(12.0, 6.0), (12.0, 18.0)], K.a(0.6), 1.5, 1.2); - p.arrow((4.5, 12.0), (10.2, 12.0), B); - p.arrow((19.5, 12.0), (13.8, 12.0), B); - } - I::SplitCells => { - p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); - p.seg((12.0, 6.0), (12.0, 18.0), K); - p.arrow((10.0, 12.0), (4.5, 12.0), B); - p.arrow((14.0, 12.0), (19.5, 12.0), B); - } - I::Borders => { - p.dotted(&[(12.0, 5.0), (12.0, 19.0)], K, 2.2, 0.45); - p.dotted(&[(5.0, 12.0), (19.0, 12.0)], K, 2.2, 0.45); - p.rect(3.0, 3.0, 21.0, 21.0, 0.6, N, B); - } - I::Shading => { - p.rect(3.0, 3.0, 12.0, 12.0, 0.0, Y.a(0.8), N); - p.rect(12.0, 12.0, 21.0, 21.0, 0.0, Y.a(0.8), N); - p.table(3.0, 3.0, 21.0, 21.0, 2, 2, N); - } - - // ----- Chart ----- - I::ChartElements => { - p.bars(2.5, 3.5, 16.0, 17.5); - p.badge((18.5, 18.5), 4.8, G, Glyph::Plus); - } - I::ChartStyles => { - p.bars(2.5, 6.0, 15.5, 20.5); - p.brush((14.0, 10.0), -45.0, 12.0, PU); - } - I::SwitchRowCol => { - p.table(2.5, 2.5, 11.0, 11.0, 2, 2, B); - p.rect(13.0, 13.0, 17.25, 21.5, 0.6, O, N); - p.table(13.0, 13.0, 21.5, 21.5, 2, 2, N); - p.line(&quad_pts((14.0, 4.0), (20.0, 4.0), (20.0, 9.5), 10), K); - p.head((20.0, 11.0), 90.0, 3.6, K); - p.line(&quad_pts((10.0, 20.0), (4.0, 20.0), (4.0, 14.5), 10), K); - p.head((4.0, 13.0), -90.0, 3.6, K); - } - I::EditData => { - p.table(2.5, 3.0, 18.0, 16.0, 3, 3, B); - p.tool((11.0, 21.5), -45.0, 14.0, 2.2, 3.8, Y, K, 2.5, R); - } - I::ChangeChartType => { - p.circle((8.0, 8.0), 5.8, t(B), B); - let mut wedge = vec![(8.0, 8.0)]; - wedge.extend(arc_pts((8.0, 8.0), 5.8, 5.8, -90.0, 0.0)); - p.poly(&wedge, O, N); - p.rect(13.0, 15.0, 15.8, 21.0, 0.4, G, N); - p.rect(17.5, 11.0, 20.3, 21.0, 0.4, B, N); - p.seg((11.5, 21.5), (22.0, 21.5), K); - } - - // ----- Media ----- - I::Play => p.rshape(&[(6.5, 4.0), (19.5, 12.0), (6.5, 20.0)], 1.0, G, N), - I::Pause => { - p.rect(6.0, 4.5, 10.0, 19.5, 1.0, K, N); - p.rect(14.0, 4.5, 18.0, 19.5, 1.0, K, N); - } - I::Stop => p.rect(5.0, 5.0, 19.0, 19.0, 2.0, K, N), - I::TrimMedia => { - p.rect(2.0, 8.0, 22.0, 16.0, 1.2, N, K); - p.rect(7.0, 8.0, 17.0, 16.0, 0.0, t(Y), N); - p.rect(5.8, 5.0, 8.2, 19.0, 0.8, Y, N); - p.rect(15.8, 5.0, 18.2, 19.0, 0.8, Y, N); - } - I::Volume => { - p.speaker(2.5, 12.0, N, K); - p.arc((11.0, 12.0), 3.8, -45.0, 45.0, B); - p.arc((11.0, 12.0), 7.0, -45.0, 45.0, B); - p.arc((11.0, 12.0), 10.2, -40.0, 40.0, B); - } - I::Loop => { - p.path_arrow(&round_pts(&[(4.0, 13.0), (4.0, 7.0), (20.0, 7.0)], 2.5, false), G); - p.path_arrow(&round_pts(&[(20.0, 11.0), (20.0, 17.0), (4.0, 17.0)], 2.5, false), G); - } - I::Bookmark => { - p.rshape(&[(6.0, 2.5), (18.0, 2.5), (18.0, 21.5), (12.0, 16.5), (6.0, 21.5)], 0.8, R, N); - } - - // ----- Window chrome ----- - I::Home => { - p.rect(10.0, 14.5, 14.0, 21.0, 0.8, B, N); - p.line(&[(2.5, 11.5), (12.0, 3.0), (21.5, 11.5)], K); - p.rline(&[(5.0, 9.5), (5.0, 21.0), (19.0, 21.0), (19.0, 9.5)], 1.2, K); - } - I::Save => { - p.rshape(&[(3.0, 3.0), (17.5, 3.0), (21.0, 6.5), (21.0, 21.0), (3.0, 21.0)], 1.5, t(B), B); - p.rect(7.0, 3.0, 15.0, 8.5, 0.6, N, B); - p.seg((12.5, 4.5), (12.5, 7.0), B); - p.rect(6.5, 13.0, 17.5, 21.0, 0.8, N, B); - } - I::Undo | I::Redo => { - let mut pts = vec![(6.0, 8.5), (14.0, 8.5)]; - pts.extend(arc_pts((14.0, 14.0), 5.5, 5.5, -90.0, 90.0)); - pts.push((8.5, 19.5)); - let hs = p.head_size(); - let pts: Vec = if icon == I::Undo { pts } else { pts.into_iter().map(|(x, y)| (24.0 - x, y)).collect() }; - p.line(&pts, K); - if icon == I::Undo { - p.head((3.0, 8.5), 180.0, hs, K); - } else { - p.head((21.0, 8.5), 0.0, hs, K); - } - } - I::More => { - for x in [5.0, 12.0, 19.0] { - p.dot((x, 12.0), 1.9, K); - } - } - I::Search => p.magnifier((10.0, 10.0), 6.5, N, K), - I::Share => { - p.seg((6.0, 12.0), (18.0, 5.5), K); - p.seg((6.0, 12.0), (18.0, 18.5), K); - p.dot((18.0, 5.5), 3.0, B); - p.dot((6.0, 12.0), 3.0, B); - p.dot((18.0, 18.5), 3.0, B); - } - I::CommentsBubble => p.bubble(2.5, 3.5, 21.5, 17.0, N, K), - I::File => { - p.page(4.5, 2.0, 19.5, 22.0, N, K); - p.seg((8.0, 12.5), (16.0, 12.5), B); - p.seg((8.0, 16.5), (14.0, 16.5), B); - } - I::Folder => { - p.rshape(&[(2.5, 4.5), (9.0, 4.5), (11.0, 7.0), (21.5, 7.0), (21.5, 19.5), (2.5, 19.5)], 1.2, t(Y), K); - p.rshape(&[(2.5, 10.0), (21.5, 10.0), (21.5, 19.5), (2.5, 19.5)], 1.2, Y.a(0.6), K); - } - I::Export => { - p.rline(&[(15.0, 8.5), (15.0, 2.5), (3.0, 2.5), (3.0, 21.5), (15.0, 21.5), (15.0, 15.5)], 1.5, K); - p.arrow((8.5, 12.0), (22.0, 12.0), B); - } - I::Print => { - p.rect(6.5, 2.5, 17.5, 9.0, 0.8, N, K); - p.rline(&[(6.5, 17.5), (2.5, 17.5), (2.5, 9.0), (21.5, 9.0), (21.5, 17.5), (17.5, 17.5)], 1.8, K); - p.rect(6.5, 14.0, 17.5, 21.5, 0.8, N, K); - p.dot((18.5, 12.0), 1.1, G); - } - I::Settings => { - p.gear((12.0, 12.0), 9.8, 7.3, 8, N, K); - p.circle((12.0, 12.0), 3.0, N, K); - } - I::Help => { - p.circle((12.0, 12.0), 9.8, N, K); - let mut q = arc_pts((12.0, 9.4), 3.2, 3.0, 180.0, 400.0); - q.push((12.0, 13.0)); - q.push((12.0, 14.6)); - p.line(&q, B); - p.dot((12.0, 17.8), 1.25, B); - } - I::Info => { - p.circle((12.0, 12.0), 9.8, N, K); - p.dot((12.0, 7.3), 1.3, B); - p.seg((12.0, 10.8), (12.0, 17.5), B); - } - I::Warning => { - p.rshape(&[(12.0, 2.5), (22.5, 20.5), (1.5, 20.5)], 2.0, Y, N); - p.seg((12.0, 9.0), (12.0, 14.0), D); - p.dot((12.0, 17.2), 1.2, D); - } - // Selection pane toggles: monochrome outlines in the ink colour. - I::Lock => { - let mut sh = vec![(7.5, 10.5), (7.5, 8.0)]; - sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); - sh.push((16.5, 10.5)); - p.line(&sh, K); - p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); - p.seg((12.0, 14.5), (12.0, 17.5), K); - } - I::Unlock => { - // The shackle swung open: its left leg out of the body, raised. - let mut sh = vec![(12.0, 10.5), (12.0, 6.0)]; - sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); - sh.push((21.0, 8.5)); - p.line(&sh, K); - p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); - p.seg((9.75, 14.5), (9.75, 17.5), K); - } - I::Eye | I::EyeOff => { - let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16); - e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1)); - e.pop(); - p.shape(&e, N, K); - p.circle((12.0, 12.0), 3.2, N, K); - if icon == I::EyeOff { - p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1); - } - } - I::Close => { - p.seg_w((5.5, 5.5), (18.5, 18.5), K, 1.1); - p.seg_w((18.5, 5.5), (5.5, 18.5), K, 1.1); - } - I::Check => p.line_w(&[(4.0, 12.5), (9.5, 18.0), (20.0, 6.5)], G, 1.3), - I::Plus => { - p.seg((12.0, 4.5), (12.0, 19.5), K); - p.seg((4.5, 12.0), (19.5, 12.0), K); - } - I::Minus => p.seg((4.5, 12.0), (19.5, 12.0), K), - I::ChevronDown => p.line(&[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)], K), - I::ChevronUp => p.line(&[(6.0, 15.0), (12.0, 9.0), (18.0, 15.0)], K), - I::ChevronLeft => p.line(&[(15.0, 6.0), (9.0, 12.0), (15.0, 18.0)], K), - I::ChevronRight => p.line(&[(9.0, 6.0), (15.0, 12.0), (9.0, 18.0)], K), - I::DragHandle => { - for x in [9.0, 15.0] { - for y in [6.0, 12.0, 18.0] { - p.dot((x, y), 1.6, K); - } - } - } - I::Star => p.star((12.0, 12.8), 10.0, t(Y), Y), - I::Image => { - p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); - p.circle((16.0, 9.0), 1.8, N, K); - p.line(&[(2.5, 17.5), (8.5, 11.0), (14.0, 16.5), (16.5, 14.0), (21.5, 18.5)], K); - } - I::Sparkle => { - p.sparkle((10.5, 13.0), 9.0, PU); - p.sparkle((19.0, 5.0), 3.8, Y); - } - I::Palette => { - let mut pts = arc_pts((12.0, 12.0), 9.8, 9.5, 70.0, 380.0); - pts.extend(cubic_pts((21.2, 15.25), (19.0, 15.0), (14.5, 14.0), (15.5, 21.0), 10)); - p.shape(&pts, N, K); - p.dot((7.5, 9.5), 1.7, R); - p.dot((11.5, 6.3), 1.7, Y); - p.dot((16.3, 7.8), 1.7, G); - p.dot((7.2, 14.8), 1.7, B); - } - I::Grid => { - for (x, y) in [(3.0, 3.0), (13.0, 3.0), (3.0, 13.0), (13.0, 13.0)] { - p.rect(x, y, x + 8.0, y + 8.0, 1.5, N, K); - } - } - I::List => { - for y in [5.5, 12.0, 18.5] { - p.dot((4.5, y), 1.5, K); - p.seg((9.0, y), (21.0, y), K); - } - } - I::Collapse => { - p.line(&[(7.0, 3.0), (12.0, 8.0), (17.0, 3.0)], K); - p.line(&[(7.0, 21.0), (12.0, 16.0), (17.0, 21.0)], K); - p.seg_w((5.0, 12.0), (19.0, 12.0), K.a(0.5), 0.8); - } - I::Expand => { - p.line(&[(7.0, 8.0), (12.0, 3.0), (17.0, 8.0)], K); - p.line(&[(7.0, 16.0), (12.0, 21.0), (17.0, 16.0)], K); - p.seg_w((5.0, 12.0), (19.0, 12.0), K.a(0.5), 0.8); - } - I::Pin => { - let m = |pts: &[P2]| tf((12.0, 12.0), 35.0, pts); - p.line(&m(&[(0.0, 2.0), (0.0, 10.5)]), K); - p.rshape(&m(&[(-6.0, 0.0), (6.0, 0.0), (6.0, 2.5), (-6.0, 2.5)]), 0.6, R, N); - p.poly(&m(&[(-3.0, -7.0), (3.0, -7.0), (3.6, 0.0), (-3.6, 0.0)]), R, N); - p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N); - } - - // ----- File tab (Backstage) navigation: outlines in the ink colour ----- - I::BackCircle => { - p.circle((12.0, 12.0), 9.8, N, K); - p.arrow((17.0, 12.0), (6.5, 12.0), K); - } - I::SaveAs => { - p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K); - p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K); - p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4); - p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0); - } - I::Account => { - p.circle((12.0, 8.0), 4.5, N, K); - p.line(&arc_pts((12.0, 22.0), 8.5, 7.5, 180.0, 360.0), K); - } - I::Recent => { - p.circle((12.0, 12.0), 9.8, N, K); - p.line(&[(12.0, 6.0), (12.0, 12.0), (16.0, 14.5)], K); - } - I::NewFile => { - p.rline(&[(14.0, 2.5), (5.0, 2.5), (5.0, 21.5), (19.0, 21.5), (19.0, 7.5), (14.0, 2.5), (14.0, 7.5), (19.0, 7.5)], 0.8, K); - } - I::OpenFolder => { - p.rline(&[(18.5, 9.0), (18.5, 6.5), (10.0, 6.5), (8.0, 4.0), (2.5, 4.0), (2.5, 19.5), (18.0, 19.5)], 1.0, K); - p.rline(&[(2.5, 19.5), (6.0, 10.0), (22.0, 10.0), (18.0, 19.5)], 1.0, K); - } - - // ----- Status bar (monochrome) ----- - I::NotesSmall => { - p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K); - p.seg((3.0, 13.5), (21.0, 13.5), K); - p.seg((6.5, 16.8), (15.5, 16.8), K); - } - I::CommentsSmall => p.bubble(3.0, 4.0, 21.0, 16.5, N, K), - I::ViewNormalSmall => { - p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); - p.seg((8.5, 4.0), (8.5, 20.0), K); - } - I::ViewSorterSmall => { - for (x, y) in [(2.5, 4.5), (13.0, 4.5), (2.5, 13.5), (13.0, 13.5)] { - p.rect(x, y, x + 8.5, y + 6.0, 1.0, N, K); - } - } - I::ViewReadingSmall => { - p.book(N, K); - p.seg((12.0, 7.0), (12.0, 20.5), K); - } - I::ViewShowSmall => { - p.seg((2.0, 4.0), (22.0, 4.0), K); - p.rect(3.5, 4.0, 20.5, 16.0, 0.8, N, K); - p.seg((12.0, 16.0), (12.0, 19.0), K); - p.line(&[(8.5, 22.0), (12.0, 19.0), (15.5, 22.0)], K); - } - I::FitSmall => { - for (cx, cy, sx, sy) in [(3.0, 3.0, 1.0, 1.0), (21.0, 3.0, -1.0, 1.0), (3.0, 21.0, 1.0, -1.0), (21.0, 21.0, -1.0, -1.0)] { - p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); - } - p.rect(8.0, 8.5, 16.0, 15.5, 0.8, N, K); - } - I::ZoomIn | I::ZoomOut => { - p.magnifier((10.0, 10.0), 7.0, N, K); - p.seg((7.0, 10.0), (13.0, 10.0), K); - if icon == I::ZoomIn { - p.seg((10.0, 7.0), (10.0, 13.0), K); - } - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs new file mode 100644 index 000000000..19a0a584c --- /dev/null +++ b/crates/ui-egui/src/icons/draw.rs @@ -0,0 +1,1341 @@ +use super::geometry::{B, C, D, G, Glyph, K, N, O, P2, PU, Pen, R, W, Y, arc_pts, cubic_pts, quad_pts, rad, round_pts, rrect_pts, t, tf}; +use super::*; + +// --------------------------------------------------------------------------------------------- +// The icons +// --------------------------------------------------------------------------------------------- + +#[expect(clippy::too_many_lines)] +pub(super) fn draw(p: &Pen<'_>, icon: Icon) { + use Icon as I; + match icon { + // ----- Clipboard & slides ----- + I::Paste => { + p.rline(&[(10.5, 21.5), (3.5, 21.5), (3.5, 4.5), (17.0, 4.5), (17.0, 9.0)], 2.0, K); + p.rect(7.0, 2.5, 13.5, 6.5, 1.2, Y, K); + p.rect(10.5, 10.0, 21.0, 22.0, 1.2, t(B), B); + p.seg((13.0, 14.0), (18.5, 14.0), B); + p.seg((13.0, 18.0), (17.0, 18.0), B); + } + I::Cut => { + p.seg((8.6, 14.6), (16.5, 2.5), K); + p.seg((15.4, 14.6), (7.5, 2.5), K); + p.circle((6.8, 17.6), 3.3, N, O); + p.circle((17.2, 17.6), 3.3, N, O); + p.dot((12.0, 9.4), 1.0, K); + } + I::Copy => { + p.rline(&[(8.0, 16.0), (3.0, 16.0), (3.0, 2.5), (14.0, 2.5), (14.0, 7.0)], 1.5, K); + p.rect(8.0, 7.5, 20.5, 21.5, 1.5, t(B), B); + p.seg((11.0, 12.5), (17.5, 12.5), B); + p.seg((11.0, 16.5), (16.0, 16.5), B); + } + I::FormatPainter => { + p.rect(2.5, 3.0, 17.0, 9.0, 1.5, Y, K); + p.rline(&[(17.0, 6.0), (20.5, 6.0), (20.5, 11.5), (11.0, 11.5), (11.0, 14.0)], 1.2, K); + p.rect(9.0, 14.0, 13.0, 21.5, 1.0, K, K); + } + I::NewSlide => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), B); + p.seg((5.0, 12.0), (10.0, 12.0), K.a(0.5)); + p.badge((18.0, 17.5), 5.0, G, Glyph::Plus); + } + I::Layout => { + p.frame(2.0, 3.5, 22.0, 20.5); + p.rect(5.0, 6.5, 19.0, 9.0, 0.8, B, N); + p.rect(5.0, 11.5, 11.0, 17.5, 0.8, t(G), G); + p.rect(13.0, 11.5, 19.0, 17.5, 0.8, t(O), O); + } + I::ResetSlide => { + p.frame(2.0, 3.5, 22.0, 20.5); + p.arc_arrow((12.0, 12.0), 4.6, -20.0, -290.0, B); + } + I::Section => { + p.rshape(&[(2.5, 2.5), (13.0, 2.5), (16.5, 5.75), (13.0, 9.0), (2.5, 9.0)], 0.6, PU, N); + p.rline(&[(5.0, 9.0), (5.0, 16.0), (7.5, 16.0)], 1.0, K); + p.rect(7.5, 12.0, 21.5, 20.5, 1.2, N, K); + p.seg((10.0, 15.5), (16.0, 15.5), K.a(0.5)); + } + I::DuplicateSlide => { + p.rline(&[(6.5, 15.5), (2.5, 15.5), (2.5, 3.5), (16.5, 3.5), (16.5, 8.0)], 1.5, K); + p.rect(6.5, 8.0, 21.5, 20.5, 1.5, t(B), B); + p.seg((14.0, 11.5), (14.0, 17.0), B); + p.seg((11.25, 14.25), (16.75, 14.25), B); + } + I::DeleteSlide => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), B); + p.seg((5.0, 12.0), (10.0, 12.0), K.a(0.5)); + p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); + } + I::HideSlide => { + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K.a(0.55)); + p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.55)); + p.seg((3.0, 21.0), (21.0, 3.0), R); + } + I::Slide => { + p.frame(2.0, 4.5, 22.0, 19.5); + p.seg((5.0, 8.5), (15.0, 8.5), B); + p.rect(5.0, 11.5, 19.0, 16.5, 0.8, t(B), N); + } + + // ----- Font ----- + I::FontGrow => { + p.letter_a(2.5, 4.0, 15.0, 20.5, K, 1.15); + p.poly(&[(16.0, 10.5), (22.5, 10.5), (19.25, 5.0)], B, N); + } + I::FontShrink => { + p.letter_a(3.0, 8.0, 13.5, 20.5, K, 1.0); + p.poly(&[(16.0, 5.0), (22.5, 5.0), (19.25, 10.5)], B, N); + } + I::ClearFormatting => { + p.letter_a(2.5, 3.0, 14.0, 17.5, K, 1.1); + let m = |pts: &[P2]| tf((17.0, 16.5), -45.0, pts); + p.poly(&m(&[(-5.5, -2.8), (0.0, -2.8), (0.0, 2.8), (-5.5, 2.8)]), R, R); + p.poly(&m(&[(0.0, -2.8), (5.5, -2.8), (5.5, 2.8), (0.0, 2.8)]), N, K); + } + I::Bold => p.text_bold("B", (12.0, 12.0), 22.0, K), + I::Italic => { + p.seg_w((10.5, 4.0), (18.5, 4.0), K, 1.1); + p.seg_w((5.5, 20.0), (13.5, 20.0), K, 1.1); + p.seg_w((14.5, 4.0), (9.5, 20.0), K, 1.1); + } + I::Underline => { + let mut pts = vec![(6.5, 3.0)]; + pts.extend(arc_pts((12.0, 11.5), 5.5, 5.5, 180.0, 0.0)); + pts.push((17.5, 3.0)); + p.line_w(&pts, K, 1.1); + p.seg_w((4.5, 21.0), (19.5, 21.0), B, 1.2); + } + I::Strikethrough => { + p.text("ab", (12.0, 11.0), 19.0, K); + p.seg_w((2.5, 13.0), (21.5, 13.0), R, 1.1); + } + I::Superscript => { + p.seg((3.0, 9.0), (13.0, 21.0), K); + p.seg((13.0, 9.0), (3.0, 21.0), K); + p.digit(2, 16.0, 2.5, 6.5, B); + } + I::Subscript => { + p.seg((3.0, 3.5), (13.0, 15.5), K); + p.seg((13.0, 3.5), (3.0, 15.5), K); + p.digit(2, 16.0, 15.0, 6.5, B); + } + I::CharSpacing => { + p.letter_a(2.0, 3.0, 10.5, 15.0, K, 1.0); + p.line(&[(13.5, 3.0), (17.75, 15.0), (22.0, 3.0)], K); + p.arrow2((2.5, 20.0), (21.5, 20.0), B); + } + I::ChangeCase => { + p.text("A", (8.0, 11.0), 19.0, K); + p.text("a", (17.5, 12.5), 16.0, B); + p.seg((3.0, 21.0), (21.0, 21.0), K.a(0.4)); + } + I::Highlight => { + let m = |pts: &[P2]| tf((8.0, 15.0), -45.0, pts); + p.poly(&m(&[(0.0, -1.2), (3.0, -3.0), (3.0, 3.0), (0.0, 1.4)]), Y, K); + p.poly(&m(&[(3.0, -3.0), (13.5, -3.0), (13.5, 3.0), (3.0, 3.0)]), N, K); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, Y, N); + } + I::FontColor => { + p.letter_a(4.5, 2.5, 19.5, 17.0, K, 1.1); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, R, N); + } + I::TextShadow => { + p.text("S", (14.0, 13.8), 22.0, K.a(0.3)); + p.text_bold("S", (11.0, 11.0), 22.0, K); + } + + // ----- Paragraph ----- + I::Bullets => { + for y in [5.5, 12.0, 18.5] { + p.dot((4.5, y), 1.8, B); + p.seg((9.0, y), (21.0, y), K); + } + } + I::Numbering => { + for (i, y) in [5.5_f32, 12.0, 18.5].into_iter().enumerate() { + p.digit(i as u8 + 1, 2.8, y - 2.6, 5.2, B); + p.seg((9.5, y), (21.0, y), K); + } + } + I::DecreaseIndent | I::IncreaseIndent => { + p.seg((3.0, 4.0), (21.0, 4.0), K); + p.seg((11.0, 9.5), (21.0, 9.5), K); + p.seg((11.0, 14.5), (21.0, 14.5), K); + p.seg((3.0, 20.0), (21.0, 20.0), K); + if icon == I::DecreaseIndent { + p.poly(&[(8.0, 8.0), (8.0, 16.0), (3.0, 12.0)], B, N); + } else { + p.poly(&[(3.0, 8.0), (3.0, 16.0), (8.0, 12.0)], B, N); + } + } + I::LineSpacing => { + for y in [5.0, 12.0, 19.0] { + p.seg((11.0, y), (21.0, y), K); + } + p.arrow2((5.0, 2.5), (5.0, 21.5), B); + } + I::Columns => { + for y in [4.5, 9.5, 14.5, 19.5] { + p.seg((2.5, y), (10.5, y), K); + p.seg((13.5, y), (21.5, y), K); + } + } + I::AlignLeft | I::AlignCenter | I::AlignRight | I::Justify => { + let rows: [(f32, f32); 4] = match icon { + I::AlignLeft => [(3.0, 21.0), (3.0, 15.0), (3.0, 21.0), (3.0, 13.0)], + I::AlignCenter => [(3.0, 21.0), (7.0, 17.0), (3.0, 21.0), (8.0, 16.0)], + I::AlignRight => [(3.0, 21.0), (9.0, 21.0), (3.0, 21.0), (11.0, 21.0)], + _ => [(3.0, 21.0), (3.0, 21.0), (3.0, 21.0), (3.0, 14.0)], + }; + for ((a, b), y) in rows.into_iter().zip([4.5, 9.5, 14.5, 19.5]) { + p.seg((a, y), (b, y), K); + } + } + I::TextDirection => { + // A capital A lying on its side. + p.line_w(&[(17.0, 5.5), (3.0, 12.0), (17.0, 18.5)], K, 1.1); + p.seg_w((11.7, 8.3), (11.7, 15.7), K, 1.1); + p.arrow((21.0, 3.5), (21.0, 20.5), B); + } + I::AlignText => { + p.rect(2.5, 3.0, 15.0, 21.0, 1.2, N, K); + p.seg((5.5, 10.0), (12.0, 10.0), K); + p.seg((5.5, 14.0), (10.5, 14.0), K); + p.arrow((19.5, 2.5), (19.5, 9.5), B); + p.arrow((19.5, 21.5), (19.5, 14.5), B); + } + I::ConvertSmartArt => { + for (y, x1) in [(4.0, 11.0), (8.0, 9.0), (12.0, 11.0)] { + p.seg((2.5, y), (x1, y), K); + } + p.path_arrow(&[(14.0, 6.0), (19.0, 6.0), (19.0, 12.5)], B); + p.rect(2.5, 15.5, 7.5, 21.5, 1.0, B, N); + p.rect(9.5, 15.5, 14.5, 21.5, 1.0, O, N); + p.rect(16.5, 15.5, 21.5, 21.5, 1.0, G, N); + } + + // ----- Drawing ----- + I::Shapes => { + p.rect(11.0, 2.5, 21.5, 13.0, 1.0, t(G), G); + p.rshape(&[(15.25, 11.0), (21.5, 21.5), (9.0, 21.5)], 0.8, t(O), O); + p.circle((8.0, 9.0), 5.5, t(B), B); + } + I::Arrange => { + p.rect(2.5, 2.5, 11.5, 11.5, 1.0, N, K); + p.rect(7.5, 7.5, 16.5, 16.5, 1.0, t(B), B); + p.rect(12.5, 12.5, 21.5, 21.5, 1.0, B, B); + } + I::QuickStyles => { + p.rect(3.0, 3.0, 11.0, 11.0, 1.5, B, N); + p.rect(13.0, 3.0, 21.0, 11.0, 1.5, N, O); + p.rect(3.0, 13.0, 11.0, 21.0, 1.5, t(G), G); + p.rect(13.0, 13.0, 21.0, 21.0, 1.5, PU, N); + } + I::ShapeFill => { + let m = |pts: &[P2]| tf((10.0, 10.0), 45.0, pts); + p.poly(&m(&[(-5.0, 0.0), (5.0, 0.0), (5.0, 5.0), (-5.0, 5.0)]), B, N); + p.rshape(&m(&[(-5.0, -5.0), (5.0, -5.0), (5.0, 5.0), (-5.0, 5.0)]), 0.8, N, K); + p.line(&quad_pts((5.2, 6.4), (5.5, 1.0), (10.0, 3.0), 10), K); + p.poly(&[(18.5, 8.5), (20.6, 12.6), (16.4, 12.6)], B, N); + p.dot((18.5, 13.4), 2.1, B); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + } + I::ShapeOutline => { + p.tool((4.5, 15.5), -45.0, 17.0, 2.4, 4.2, N, K, 0.0, N); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + } + I::ShapeEffects => { + p.rect(6.0, 8.0, 18.5, 20.5, 2.0, K.a(0.25), N); + p.rect(3.0, 5.0, 15.5, 17.5, 2.0, B, N); + p.sparkle((19.0, 5.0), 4.0, Y); + } + I::TextFill => { + p.letter_a(4.5, 2.5, 19.5, 17.0, B, 1.5); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + } + I::TextOutline => { + let outer = [(3.5, 17.0), (9.6, 2.5), (14.4, 2.5), (20.5, 17.0), (16.6, 17.0), (15.2, 13.2), (8.8, 13.2), (7.4, 17.0)]; + p.shape_w(&outer, N, K, 0.8); + p.poly_w(&[(10.0, 10.0), (12.0, 5.0), (14.0, 10.0)], N, K, 0.8); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + } + I::TextEffects => { + p.letter_a(3.0, 5.0, 17.0, 21.0, PU, 1.3); + p.sparkle((19.0, 5.0), 4.5, Y); + } + I::EditShape => { + let a = (4.0, 19.0); + let b = (11.0, 4.5); + let c = (20.0, 17.5); + let mut pts = vec![a, b]; + pts.extend(cubic_pts(b, (17.0, 4.0), (23.0, 11.0), c, 16)); + pts.extend(cubic_pts(c, (15.0, 22.0), (9.0, 17.0), a, 16)); + p.shape(&pts, t(B), K); + for q in [a, b, c] { + p.rect(q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6, 0.3, B, N); + } + } + I::MergeShapes => { + let mut lens = arc_pts((9.0, 12.0), 6.5, 6.5, -62.5, 62.5); + lens.extend(arc_pts((15.0, 12.0), 6.5, 6.5, 117.5, 242.5)); + p.poly(&lens, PU, N); + p.circle((9.0, 12.0), 6.5, N, K); + p.circle((15.0, 12.0), 6.5, N, K); + } + + // ----- Insert ----- + I::Table => p.table(2.5, 4.0, 21.5, 20.0, 3, 3, B), + I::Picture => p.picture(2.0, 4.0, 22.0, 20.0), + I::Screenshot => { + for (cx, cy, sx, sy) in [(2.5, 3.5, 1.0, 1.0), (21.5, 3.5, -1.0, 1.0), (2.5, 20.5, 1.0, -1.0), (21.5, 20.5, -1.0, -1.0)] { + p.rline(&[(cx, cy + 4.0 * sy), (cx, cy), (cx + 4.0 * sx, cy)], 1.0, K); + } + p.rect(6.5, 7.5, 17.5, 16.5, 1.0, t(B), B); + p.seg((6.5, 10.0), (17.5, 10.0), B); + } + I::Icons => { + p.person(6.75, 2.5, 0.75, B); + p.star((17.0, 7.0), 5.0, Y, N); + let heart: Vec = (0..40) + .map(|i| { + let tt = i as f32 / 40.0 * 2.0 * PI; + let x = 16.0 * tt.sin().powi(3); + let y = 13.0 * tt.cos() - 5.0 * (2.0 * tt).cos() - 2.0 * (3.0 * tt).cos() - (4.0 * tt).cos(); + (7.0 + x * 0.29, 17.0 - y * 0.29) + }) + .collect(); + p.shape(&heart, R, N); + p.rshape(&[(17.0, 12.5), (21.5, 17.0), (17.0, 21.5), (12.5, 17.0)], 0.8, G, N); + } + I::Models3d => { + let (top, ur, lr, bot, ll, ul, mid) = ((12.0, 2.5), (20.5, 7.2), (20.5, 16.8), (12.0, 21.5), (3.5, 16.8), (3.5, 7.2), (12.0, 11.9)); + p.poly(&[top, ur, mid, ul], t(B), N); + p.poly(&[ul, mid, bot, ll], B.a(0.55), N); + p.poly(&[mid, ur, lr, bot], B, N); + p.shape_w(&[top, ur, lr, bot, ll, ul], N, K, 1.0); + p.line(&[ul, mid, ur], K); + p.seg(mid, bot, K); + } + I::SmartArt => { + p.rect(8.5, 2.5, 15.5, 8.5, 1.0, G, N); + p.seg((12.0, 8.5), (12.0, 11.5), K); + p.rline(&[(5.5, 14.5), (5.5, 11.5), (18.5, 11.5), (18.5, 14.5)], 1.0, K); + p.rect(2.0, 14.5, 9.0, 20.5, 1.0, B, N); + p.rect(15.0, 14.5, 22.0, 20.5, 1.0, O, N); + } + I::Chart => p.bars(2.5, 4.0, 21.5, 20.5), + I::Zoom => { + p.rline(&[(10.5, 15.0), (2.0, 15.0), (2.0, 3.0), (18.0, 3.0), (18.0, 9.5)], 1.5, K); + p.seg((5.0, 6.5), (11.0, 6.5), K.a(0.5)); + p.magnifier((14.5, 14.5), 4.5, t(B), B); + } + I::Link => { + let link = |c: P2, col: C| { + let local = rrect_pts(-5.5, -2.8, 5.5, 2.8, 2.8); + p.shape(&tf(c, -45.0, &local), N, col); + }; + link((8.5, 15.5), K); + link((15.5, 8.5), B); + } + I::Action => { + p.rect(2.5, 3.5, 18.0, 12.5, 2.0, t(B), B); + p.cursor(11.5, 8.5, 0.82); + } + I::Comment => { + p.bubble(2.5, 3.5, 21.5, 16.5, t(Y), K); + p.seg((6.5, 8.0), (17.5, 8.0), K); + p.seg((6.5, 12.0), (14.0, 12.0), K); + } + I::TextBox => { + p.rect(3.5, 4.5, 20.5, 19.5, 0.5, N, K); + for (x, y) in [(3.5, 4.5), (20.5, 4.5), (3.5, 19.5), (20.5, 19.5)] { + p.rect(x - 1.5, y - 1.5, x + 1.5, y + 1.5, 0.3, B, N); + } + p.seg((7.5, 9.5), (16.5, 9.5), K); + p.seg((7.5, 14.5), (13.5, 14.5), K); + } + I::HeaderFooter => { + p.rect(4.0, 2.5, 20.0, 21.5, 1.2, N, K); + p.rect(6.5, 5.0, 17.5, 7.5, 0.6, B, N); + p.rect(6.5, 16.5, 17.5, 19.0, 0.6, B, N); + p.seg_w((7.0, 10.5), (17.0, 10.5), K.a(0.45), 0.8); + p.seg_w((7.0, 13.2), (14.5, 13.2), K.a(0.45), 0.8); + } + I::WordArt => { + p.text_rot("A", (13.5, 13.5), 22.0, PU, -10.0); + p.text_rot("A", (11.5, 11.5), 22.0, O, -10.0); + p.text_rot("A", (11.3, 11.5), 22.0, O, -10.0); + } + I::DateTime => { + p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); + p.rect(3.0, 5.0, 21.0, 9.5, 1.6, R, N); + p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); + p.seg((8.0, 2.8), (8.0, 6.5), K); + p.seg((16.0, 2.8), (16.0, 6.5), K); + for (i, (x, y)) in [(8.0, 13.5), (12.0, 13.5), (16.0, 13.5), (8.0, 17.5), (12.0, 17.5), (16.0, 17.5)].into_iter().enumerate() { + p.dot((x, y), 1.1, if i == 4 { B } else { K }); + } + } + I::SlideNumber => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.seg((10.7, 7.5), (9.7, 16.5), B); + p.seg((15.0, 7.5), (14.0, 16.5), B); + p.seg((7.5, 10.5), (17.0, 10.5), B); + p.seg((7.0, 13.6), (16.5, 13.6), B); + } + I::Object => { + p.page(3.0, 2.0, 16.0, 19.0, N, K); + p.rect(11.5, 11.5, 21.5, 21.5, 1.6, B, N); + p.rect(14.2, 14.2, 18.8, 18.8, 0.6, N, W); + } + I::Equation => { + p.line_w(&[(18.5, 6.5), (18.0, 4.0), (6.0, 4.0), (12.5, 12.0), (6.0, 20.0), (18.0, 20.0), (18.5, 17.5)], B, 1.15); + } + I::Symbol => p.text("Ω", (12.0, 12.0), 22.0, K), + I::Video => { + p.rect(2.0, 6.0, 15.5, 18.0, 2.0, t(B), K); + p.rshape(&[(16.5, 10.5), (22.0, 7.0), (22.0, 17.0), (16.5, 13.5)], 0.6, N, K); + p.dot((6.0, 10.0), 1.2, R); + } + I::Audio => { + p.speaker(2.5, 12.0, t(PU), K); + p.arc((11.0, 12.0), 4.5, -45.0, 45.0, PU); + p.arc((11.0, 12.0), 8.5, -45.0, 45.0, PU); + } + I::ScreenRecording => { + p.monitor(2.0, 3.0, 22.0, 16.5); + p.circle((12.0, 9.75), 3.0, R, N); + } + I::Cameo => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.circle((12.0, 12.0), 5.6, t(B), B); + p.dot((12.0, 10.4), 1.9, B); + p.poly(&arc_pts((12.0, 16.0), 3.4, 2.6, 180.0, 360.0), B, N); + } + + // ----- Draw tab ----- + I::Pen => { + p.tool((3.5, 20.5), -45.0, 23.0, 2.2, 5.0, B, K, 3.5, K); + } + I::Pencil => { + p.tool((3.5, 20.5), -45.0, 22.0, 2.6, 5.0, Y, K, 3.5, R); + } + I::HighlighterPen => { + let m = |pts: &[P2]| tf((6.0, 15.0), -45.0, pts); + p.poly(&m(&[(0.0, -1.0), (3.0, -3.2), (3.0, 3.2), (0.0, 2.2)]), Y, K); + p.poly(&m(&[(3.0, -3.6), (14.5, -3.6), (14.5, 3.6), (3.0, 3.6)]), t(Y), K); + p.rect(2.0, 19.5, 13.0, 22.5, 0.8, Y, N); + } + I::Eraser => { + let m = |pts: &[P2]| tf((12.5, 11.0), -45.0, pts); + p.poly(&m(&[(-8.0, -4.0), (-1.0, -4.0), (-1.0, 4.0), (-8.0, 4.0)]), R, N); + p.rshape(&m(&[(-8.0, -4.0), (8.0, -4.0), (8.0, 4.0), (-8.0, 4.0)]), 1.0, N, K); + p.seg((12.0, 21.0), (21.0, 21.0), K); + } + I::LassoSelect => { + let mut e = arc_pts((13.0, 9.0), 8.5, 6.0, 120.0, 480.0); + e.truncate(e.len().saturating_sub(1)); + p.dashed(&e, B, 2.6, 1.7); + p.curve((8.8, 14.2), (5.5, 16.5), (10.0, 19.0), (5.5, 22.0), K); + p.dot((8.8, 14.2), 1.4, K); + } + I::InkToShape => { + p.scribble(1.5, 2.0, 12.5, 11.0, K); + p.circle((16.5, 16.5), 5.0, t(B), B); + } + I::InkToText => { + p.scribble(1.5, 2.0, 12.5, 11.0, K); + p.letter_a(12.0, 9.0, 21.5, 21.5, B, 1.2); + } + I::InkToMath => { + p.scribble(1.5, 2.0, 12.5, 11.0, K); + p.line_w(&[(21.0, 11.0), (12.5, 11.0), (17.0, 16.25), (12.5, 21.5), (21.0, 21.5)], B, 1.1); + } + I::Ruler => { + let m = |pts: &[P2]| tf((12.0, 12.0), -45.0, pts); + p.rshape(&m(&[(-11.5, -4.0), (11.5, -4.0), (11.5, 4.0), (-11.5, 4.0)]), 0.8, Y.a(0.85), K); + for (i, u) in [-8.0_f32, -5.0, -2.0, 1.0, 4.0, 7.0].into_iter().enumerate() { + let l = if i % 2 == 0 { 3.2 } else { 1.8 }; + p.line_w(&m(&[(u, -4.0), (u, -4.0 + l)]), D, 0.8); + } + } + + // ----- Design ----- + I::Themes => { + p.rect(9.5, 2.5, 21.5, 14.5, 2.0, O, N); + p.rect(2.5, 8.5, 15.0, 21.5, 2.0, B, N); + p.text("Aa", (8.75, 15.0), 10.0, W); + } + I::Variants => { + p.rect(8.0, 2.5, 21.5, 11.0, 1.2, t(PU), PU); + p.rect(5.0, 7.0, 18.5, 15.5, 1.2, t(G), G); + p.rect(2.5, 12.0, 16.0, 21.0, 1.2, B, B); + } + I::Colors => { + for (i, c) in [R, Y, G, B].into_iter().enumerate() { + let x = 3.0 + i as f32 * 4.5; + p.rect(x, 4.0, x + 4.5, 20.0, 0.0, c, N); + } + p.rect(3.0, 4.0, 21.0, 20.0, 0.0, N, K); + } + I::Fonts => { + p.text("A", (8.0, 11.0), 20.0, K); + p.text("g", (17.0, 10.0), 18.0, B); + } + I::BackgroundStyles => { + p.poly(&[(2.7, 4.7), (21.3, 4.7), (2.7, 19.3)], t(B), N); + p.poly(&[(21.3, 4.7), (21.3, 19.3), (2.7, 19.3)], PU.a(0.7), N); + p.frame(2.0, 4.0, 22.0, 20.0); + } + I::SlideSize => { + p.rline(&[(17.0, 12.0), (17.0, 19.0), (2.5, 19.0), (2.5, 7.0), (10.0, 7.0)], 1.5, K); + p.arrow((9.0, 13.0), (21.5, 2.5), B); + } + I::FormatBackground => { + p.rline(&[(10.0, 17.0), (2.0, 17.0), (2.0, 4.0), (20.0, 4.0), (20.0, 9.0)], 1.5, K); + p.rect(4.5, 6.5, 13.5, 12.5, 0.6, t(PU), N); + p.brush((12.0, 20.5), -45.0, 13.0, Y); + } + I::DesignIdeas => { + p.circle((12.0, 9.5), 6.0, Y.a(0.5), K); + p.seg((9.6, 15.7), (9.6, 17.0), K); + p.seg((14.4, 15.7), (14.4, 17.0), K); + p.seg((9.5, 18.0), (14.5, 18.0), K); + p.seg((10.5, 21.0), (13.5, 21.0), K); + for a in [-160.0_f32, -125.0, -55.0, -20.0] { + let (s, c) = rad(a).sin_cos(); + p.seg((12.0 + c * 8.0, 9.5 + s * 8.0), (12.0 + c * 10.0, 9.5 + s * 10.0), Y); + } + } + + // ----- Transitions / animations ----- + I::Preview => { + p.circle((12.0, 12.0), 9.5, N, K); + p.rshape(&[(9.5, 7.0), (17.0, 12.0), (9.5, 17.0)], 0.8, G, N); + } + I::EffectOptions => { + p.rect(2.5, 8.5, 15.5, 21.5, 1.5, t(B), K); + p.arrow((9.0, 15.0), (21.5, 2.5), B); + } + I::Sound => { + let head = |c: P2| { + let pts = tf(c, -22.0, &arc_pts((0.0, 0.0), 3.0, 2.1, 0.0, 351.0)); + p.poly(&pts, PU, N); + }; + head((6.3, 18.0)); + head((16.3, 15.5)); + p.seg((9.0, 17.0), (9.0, 5.0), PU); + p.seg((19.0, 14.5), (19.0, 2.5), PU); + p.poly(&[(9.0, 4.5), (19.0, 2.0), (19.0, 5.5), (9.0, 8.0)], PU, N); + } + I::Duration => { + p.circle((12.0, 13.5), 8.0, N, K); + p.rect(10.0, 2.0, 14.0, 4.0, 0.6, K, N); + p.seg((12.0, 4.0), (12.0, 5.5), K); + p.seg((12.0, 13.5), (15.8, 9.0), B); + p.dot((12.0, 13.5), 1.3, B); + } + I::ApplyToAll => { + p.rline(&[(6.0, 7.5), (6.0, 3.0), (21.5, 3.0), (21.5, 13.5), (17.5, 13.5)], 1.5, K); + p.rect(2.5, 7.5, 17.5, 18.0, 1.5, N, K); + p.badge((18.0, 18.0), 4.8, G, Glyph::Check); + } + I::AnimEntrance => { + p.star((13.5, 12.5), 8.5, G, N); + p.seg((1.5, 9.0), (4.5, 9.0), K); + p.seg((0.8, 13.5), (4.0, 13.5), K); + } + I::AnimEmphasis => { + p.star((12.0, 12.5), 9.5, Y, N); + } + I::AnimExit => { + p.star((10.5, 12.5), 8.5, R, N); + p.seg((19.5, 9.0), (22.5, 9.0), K); + p.seg((20.0, 13.5), (23.2, 13.5), K); + } + I::AnimPath => { + let pts = cubic_pts((4.5, 19.5), (3.0, 6.0), (20.0, 19.0), (19.5, 5.0), 60); + p.dotted(&pts[6..pts.len().saturating_sub(4)], K, 2.4, 0.55); + p.head((19.5, 4.0), -90.0, 4.0, K); + p.circle((4.5, 19.5), 2.4, G, N); + } + I::AnimationPane => { + p.rect(2.5, 3.0, 21.5, 21.0, 1.5, N, K); + p.seg((11.0, 3.0), (11.0, 21.0), K); + p.rect(4.5, 7.0, 8.5, 12.0, 0.5, t(B), N); + for (y, c) in [(7.5, G), (12.0, Y), (16.5, R)] { + p.seg_w((13.5, y), (19.0, y), c, 1.4); + } + } + I::Trigger => { + p.rshape(&[(14.5, 2.0), (5.0, 13.5), (11.5, 13.5), (9.5, 22.0), (19.0, 9.5), (12.5, 9.5)], 0.6, Y, K); + } + I::AnimationPainter => { + p.star((7.5, 7.5), 6.0, G, N); + p.brush((12.0, 12.0), 45.0, 13.0, Y); + } + I::AddAnimation => { + p.star((10.5, 11.0), 9.0, Y, N); + p.badge((18.5, 18.5), 4.8, G, Glyph::Plus); + } + + // ----- Slide show ----- + I::PlayFromStart => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.rect(7.0, 8.0, 9.2, 16.0, 0.5, G, N); + p.rshape(&[(11.0, 8.0), (17.5, 12.0), (11.0, 16.0)], 0.6, G, N); + } + I::PlayFromCurrent => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.rshape(&[(9.5, 8.0), (16.0, 12.0), (9.5, 16.0)], 0.6, G, N); + } + I::PresenterView => { + p.monitor(2.0, 3.0, 22.0, 16.5); + p.rect(4.5, 5.5, 13.0, 12.0, 0.6, t(B), B); + p.seg_w((15.0, 6.5), (19.5, 6.5), K, 0.8); + p.seg_w((15.0, 9.5), (19.5, 9.5), K, 0.8); + p.seg_w((15.0, 12.5), (18.0, 12.5), K, 0.8); + } + I::CustomShow => { + p.rect(2.5, 2.5, 12.5, 8.0, 1.0, N, K); + p.rect(2.5, 9.5, 12.5, 15.0, 1.0, O, O); + p.rect(2.5, 16.5, 12.5, 22.0, 1.0, N, K); + p.rshape(&[(15.5, 8.0), (21.5, 12.25), (15.5, 16.5)], 0.6, G, N); + } + I::SetUpShow => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.5)); + p.gear_badge((17.5, 17.5), 5.5, B); + } + I::RehearseTimings => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.5)); + p.clock_badge((17.5, 17.5), 5.2, B); + } + I::Record => { + p.circle((12.0, 12.0), 9.5, N, K); + p.circle((12.0, 12.0), 5.5, R, N); + } + I::Subtitles => { + p.rect(2.0, 4.5, 22.0, 19.5, 2.0, N, K); + p.seg((5.5, 12.0), (11.0, 12.0), B); + p.seg((13.5, 12.0), (18.5, 12.0), B); + p.seg((5.5, 16.0), (8.5, 16.0), B); + p.seg((11.0, 16.0), (18.5, 16.0), B); + } + I::Coach => { + p.person(9.0, 5.0, 1.2, t(B)); + p.circle((9.0, 8.1), 3.1, N, B); + p.line(&arc_pts((9.0, 19.4), 6.2, 6.0, 180.0, 360.0), B); + p.star((18.5, 6.0), 4.2, Y, N); + } + + // ----- Review ----- + I::Spelling => { + p.text("abc", (11.5, 7.0), 12.0, K); + p.line_w(&[(6.0, 16.0), (10.0, 20.0), (19.5, 11.0)], G, 1.3); + } + I::Thesaurus => { + p.rect(8.0, 2.5, 19.5, 21.5, 1.2, t(B), N); + p.rect(4.5, 2.5, 19.5, 21.5, 1.5, N, K); + p.seg((8.0, 2.5), (8.0, 21.5), K); + p.poly(&[(13.5, 2.5), (17.0, 2.5), (17.0, 9.0), (15.25, 7.5), (13.5, 9.0)], R, N); + } + I::Accessibility => { + p.circle((12.0, 12.0), 9.8, N, B); + p.dot((12.0, 6.5), 1.6, B); + p.seg((6.8, 9.6), (17.2, 9.6), B); + p.seg((12.0, 9.6), (12.0, 13.8), B); + p.line(&[(9.0, 18.5), (12.0, 13.8), (15.0, 18.5)], B); + } + I::Translate => { + p.rect(10.0, 9.0, 21.5, 20.5, 2.0, O, N); + p.rect(2.5, 3.5, 14.0, 15.0, 2.0, B, N); + p.letter_a(5.5, 5.8, 11.0, 12.8, W, 0.9); + p.seg_w((17.75, 10.8), (17.75, 11.9), W, 0.8); + p.seg_w((15.2, 12.9), (20.3, 12.9), W, 0.8); + p.seg_w((15.6, 14.4), (19.9, 18.9), W, 0.8); + p.seg_w((19.9, 14.4), (15.6, 18.9), W, 0.8); + } + I::Language => { + p.circle((12.0, 12.0), 9.5, N, B); + p.ellipse((12.0, 12.0), 4.2, 9.5, N, B); + p.seg_w((2.5, 12.0), (21.5, 12.0), B, 0.8); + p.seg_w((4.3, 7.0), (19.7, 7.0), B, 0.8); + p.seg_w((4.3, 17.0), (19.7, 17.0), B, 0.8); + } + I::NewComment => { + p.bubble(2.0, 3.0, 19.5, 15.0, N, K); + p.badge((18.0, 17.5), 5.0, G, Glyph::Plus); + } + I::DeleteComment => { + p.bubble(2.0, 3.0, 19.5, 15.0, N, K); + p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); + } + I::PrevComment => { + p.bubble(2.5, 3.0, 21.5, 17.0, N, K); + p.arrow((16.5, 10.0), (7.0, 10.0), B); + } + I::NextComment => { + p.bubble(2.5, 3.0, 21.5, 17.0, N, K); + p.arrow((7.5, 10.0), (17.0, 10.0), B); + } + I::ShowComments => { + p.rline(&[(8.0, 7.5), (8.0, 2.5), (21.5, 2.5), (21.5, 13.0), (17.0, 13.0)], 2.0, K); + p.bubble(2.5, 7.5, 16.5, 17.5, t(Y), K); + p.seg((5.5, 11.0), (13.5, 11.0), K); + p.seg((5.5, 14.2), (11.0, 14.2), K); + } + I::Compare => { + p.rect(2.5, 2.5, 11.0, 15.5, 1.2, N, K); + p.rect(13.0, 8.5, 21.5, 21.5, 1.2, t(B), B); + p.arrow((13.0, 4.0), (21.5, 4.0), K); + p.arrow((11.0, 20.0), (2.5, 20.0), K); + } + I::ReadOnly => { + p.page(3.0, 2.0, 16.0, 20.5, N, K); + p.lock(13.0, 15.0, 21.5, 22.0); + } + + // ----- View ----- + I::ViewNormal => { + p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.seg((8.0, 3.5), (8.0, 20.5), K); + for y in [6.0, 10.5, 15.0] { + p.rect(3.9, y, 6.2, y + 2.6, 0.3, K.a(0.55), N); + } + p.rect(10.0, 6.0, 20.0, 15.0, 0.6, t(B), B); + p.seg_w((10.0, 18.0), (17.0, 18.0), K.a(0.5), 0.8); + } + I::ViewOutline => { + p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.seg((11.0, 3.5), (11.0, 20.5), K); + p.seg_w((4.0, 7.0), (9.0, 7.0), K, 0.8); + p.seg_w((5.5, 10.0), (9.0, 10.0), K, 0.8); + p.seg_w((5.5, 13.0), (9.0, 13.0), K, 0.8); + p.seg_w((4.0, 16.0), (9.0, 16.0), K, 0.8); + p.rect(13.0, 7.0, 20.0, 13.0, 0.6, t(B), B); + } + I::ViewSorter => { + for (i, (x0, x1)) in [(2.5, 11.0), (13.0, 21.5)].into_iter().enumerate() { + for (j, (y0, y1)) in [(4.0, 10.5), (13.5, 20.0)].into_iter().enumerate() { + if i == 0 && j == 0 { + p.rect(x0, y0, x1, y1, 0.8, t(B), B); + } else { + p.rect(x0, y0, x1, y1, 0.8, N, K); + } + } + } + } + I::ViewNotesPage => { + p.rect(5.0, 2.0, 19.0, 22.0, 1.2, N, K); + p.rect(7.5, 4.5, 16.5, 10.5, 0.6, t(B), B); + p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.8); + p.seg_w((7.5, 17.5), (14.0, 17.5), K, 0.8); + } + I::ViewReading => { + p.book(t(B), K); + p.seg((12.0, 7.0), (12.0, 20.5), K); + } + I::SlideMaster => { + p.rect(2.0, 2.5, 15.0, 10.5, 1.2, t(PU), PU); + p.seg((4.5, 5.5), (11.0, 5.5), PU); + p.rline(&[(5.0, 10.5), (5.0, 19.5), (9.0, 19.5)], 1.0, K); + p.seg((5.0, 14.0), (9.0, 14.0), K); + p.rect(9.0, 12.0, 17.0, 16.0, 0.8, N, K); + p.rect(9.0, 17.5, 17.0, 21.5, 0.8, N, K); + } + I::HandoutMaster => { + p.rect(4.0, 2.0, 20.0, 22.0, 1.2, N, K); + for (x, y) in [(6.5, 4.5), (12.75, 4.5), (6.5, 11.0), (12.75, 11.0)] { + p.rect(x, y, x + 4.75, y + 4.5, 0.4, t(PU), PU); + } + p.seg_w((6.5, 19.0), (17.5, 19.0), K, 0.8); + } + I::NotesMaster => { + p.rline(&[(8.0, 4.0), (8.0, 2.0), (21.0, 2.0), (21.0, 19.0), (16.0, 19.0)], 1.2, PU); + p.rect(3.0, 4.5, 16.0, 22.0, 1.2, N, K); + p.rect(5.5, 7.0, 13.5, 12.0, 0.5, t(PU), PU); + p.seg_w((5.5, 15.5), (13.5, 15.5), K, 0.8); + p.seg_w((5.5, 18.8), (11.5, 18.8), K, 0.8); + } + I::Gridlines => { + for v in [9.0, 15.0] { + p.seg_w((v, 3.0), (v, 21.0), K.a(0.6), 0.7); + p.seg_w((3.0, v), (21.0, v), K.a(0.6), 0.7); + } + p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + } + I::Guides => { + p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.dashed(&[(12.0, 1.5), (12.0, 22.5)], B, 2.2, 1.6); + p.dashed(&[(1.5, 12.0), (22.5, 12.0)], B, 2.2, 1.6); + } + I::Notes => { + p.rect(4.0, 4.0, 20.0, 21.5, 1.5, t(Y), K); + for x in [8.0, 12.0, 16.0] { + p.seg((x, 2.3), (x, 5.8), K); + } + p.seg_w((7.5, 10.5), (16.5, 10.5), K, 0.85); + p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.85); + p.seg_w((7.5, 17.5), (13.5, 17.5), K, 0.85); + } + I::ZoomGlass => p.magnifier((10.0, 10.0), 7.0, t(B), K), + I::FitToWindow => { + for (cx, cy, sx, sy) in [(2.5, 2.5, 1.0, 1.0), (21.5, 2.5, -1.0, 1.0), (2.5, 21.5, 1.0, -1.0), (21.5, 21.5, -1.0, -1.0)] { + p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); + } + p.rect(7.0, 8.0, 17.0, 16.0, 1.0, t(B), B); + } + I::Grayscale => { + p.rect(3.0, 3.0, 9.0, 21.0, 0.0, K.a(0.85), N); + p.rect(9.0, 3.0, 15.0, 21.0, 0.0, K.a(0.45), N); + p.rect(15.0, 3.0, 21.0, 21.0, 0.0, K.a(0.15), N); + p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + } + I::NewWindow => { + p.rect(2.0, 3.5, 20.0, 17.0, 1.5, N, K); + p.seg((2.0, 7.0), (20.0, 7.0), K); + p.dot((4.5, 5.25), 0.6, K); + p.badge((18.5, 18.0), 4.8, G, Glyph::Plus); + } + I::Macros => { + p.page(3.0, 2.0, 17.0, 21.0, N, K); + p.line(&[(8.5, 9.0), (6.0, 11.5), (8.5, 14.0)], B); + p.line(&[(11.5, 9.0), (14.0, 11.5), (11.5, 14.0)], B); + p.gear_badge((17.5, 17.5), 5.2, O); + } + + // ----- Arrange ----- + I::BringForward => { + p.layer(10.0, 15.5, 8.0, 4.0, N, K); + p.layer(10.0, 8.5, 8.0, 4.0, B, B); + p.arrow((21.0, 13.0), (21.0, 3.0), K); + } + I::SendBackward => { + p.layer(10.0, 15.5, 8.0, 4.0, B, B); + p.layer(10.0, 8.5, 8.0, 4.0, N, K); + p.arrow((21.0, 11.0), (21.0, 21.0), K); + } + I::BringToFront => { + p.layer(10.0, 18.5, 7.5, 3.2, N, K); + p.layer(10.0, 12.0, 7.5, 3.2, N, K); + p.layer(10.0, 5.5, 7.5, 3.2, B, B); + p.arrow((20.5, 14.0), (20.5, 4.5), K); + p.seg((18.0, 2.5), (23.0, 2.5), K); + } + I::SendToBack => { + p.layer(10.0, 18.5, 7.5, 3.2, B, B); + p.layer(10.0, 12.0, 7.5, 3.2, N, K); + p.layer(10.0, 5.5, 7.5, 3.2, N, K); + p.arrow((20.5, 10.0), (20.5, 19.5), K); + p.seg((18.0, 21.5), (23.0, 21.5), K); + } + I::Group => { + p.dashed(&[(2.5, 2.5), (21.5, 2.5), (21.5, 21.5), (2.5, 21.5), (2.5, 2.5)], B, 2.0, 1.6); + p.rect(5.5, 5.5, 13.0, 13.0, 0.8, t(G), G); + p.circle((15.5, 15.5), 4.3, t(O), O); + } + I::Ungroup => { + p.dashed_w(&[(2.0, 2.0), (12.0, 2.0), (12.0, 12.0), (2.0, 12.0), (2.0, 2.0)], B, 1.6, 1.3, 0.8); + p.rect(4.5, 4.5, 9.5, 9.5, 0.6, t(G), G); + p.dashed_w(&[(12.0, 12.0), (22.0, 12.0), (22.0, 22.0), (12.0, 22.0), (12.0, 12.0)], B, 1.6, 1.3, 0.8); + p.circle((17.0, 17.0), 2.9, t(O), O); + } + I::AlignObjects => { + p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 2.0, 1.5); + p.rect(3.5, 4.0, 8.0, 20.0, 0.8, B, N); + p.rect(10.0, 7.5, 14.0, 16.5, 0.8, O, N); + p.rect(16.0, 5.5, 20.5, 18.5, 0.8, G, N); + } + I::AlignObjLeft => { + p.rect(3.5, 5.0, 19.5, 10.5, 0.8, t(B), B); + p.rect(3.5, 13.5, 13.5, 19.0, 0.8, t(B), B); + p.seg((3.5, 2.5), (3.5, 21.5), K); + } + I::AlignObjCenter => { + p.rect(4.0, 5.0, 20.0, 10.5, 0.8, t(B), B); + p.rect(7.0, 13.5, 17.0, 19.0, 0.8, t(B), B); + p.seg((12.0, 2.5), (12.0, 21.5), K); + } + I::AlignObjRight => { + p.rect(4.5, 5.0, 20.5, 10.5, 0.8, t(B), B); + p.rect(10.5, 13.5, 20.5, 19.0, 0.8, t(B), B); + p.seg((20.5, 2.5), (20.5, 21.5), K); + } + I::AlignObjTop => { + p.rect(5.0, 3.5, 10.5, 19.5, 0.8, t(B), B); + p.rect(13.5, 3.5, 19.0, 13.5, 0.8, t(B), B); + p.seg((2.5, 3.5), (21.5, 3.5), K); + } + I::AlignObjMiddle => { + p.rect(5.0, 4.0, 10.5, 20.0, 0.8, t(B), B); + p.rect(13.5, 7.0, 19.0, 17.0, 0.8, t(B), B); + p.seg((2.5, 12.0), (21.5, 12.0), K); + } + I::AlignObjBottom => { + p.rect(5.0, 4.5, 10.5, 20.5, 0.8, t(B), B); + p.rect(13.5, 10.5, 19.0, 20.5, 0.8, t(B), B); + p.seg((2.5, 20.5), (21.5, 20.5), K); + } + I::DistributeH => { + p.seg((2.5, 3.0), (2.5, 21.0), K); + p.seg((21.5, 3.0), (21.5, 21.0), K); + p.rect(4.9, 7.0, 8.2, 17.0, 0.6, t(B), B); + p.rect(10.35, 5.0, 13.65, 19.0, 0.6, t(B), B); + p.rect(15.8, 8.5, 19.1, 15.5, 0.6, t(B), B); + } + I::DistributeV => { + p.seg((3.0, 2.5), (21.0, 2.5), K); + p.seg((3.0, 21.5), (21.0, 21.5), K); + p.rect(7.0, 4.9, 17.0, 8.2, 0.6, t(B), B); + p.rect(5.0, 10.35, 19.0, 13.65, 0.6, t(B), B); + p.rect(8.5, 15.8, 15.5, 19.1, 0.6, t(B), B); + } + I::RotateLeft => { + p.poly(&[(3.0, 21.5), (3.0, 13.0), (12.0, 21.5)], t(B), B); + p.arc_arrow((13.0, 12.0), 7.5, 30.0, -160.0, K); + } + I::RotateRight => { + p.poly(&[(21.0, 21.5), (21.0, 13.0), (12.0, 21.5)], t(B), B); + p.arc_arrow((11.0, 12.0), 7.5, 150.0, 340.0, K); + } + I::FlipH => { + p.dashed(&[(12.0, 2.0), (12.0, 22.0)], K, 1.8, 1.4); + p.poly(&[(9.5, 5.0), (9.5, 19.0), (2.5, 19.0)], N, K); + p.poly(&[(14.5, 5.0), (21.5, 19.0), (14.5, 19.0)], B, B); + } + I::FlipV => { + p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 1.8, 1.4); + p.poly(&[(5.0, 9.5), (19.0, 9.5), (19.0, 2.5)], N, K); + p.poly(&[(5.0, 14.5), (19.0, 21.5), (19.0, 14.5)], B, B); + } + I::SelectionPane => { + p.rect(3.0, 2.5, 21.0, 21.5, 1.5, N, K); + p.rect(4.6, 4.6, 19.4, 9.4, 0.8, t(B), N); + for (y, c) in [(7.0, B), (12.0, O), (17.0, G)] { + p.rect(6.0, y - 1.3, 8.6, y + 1.3, 0.4, c, N); + p.seg_w((10.5, y), (17.5, y), K, 0.85); + } + } + I::Rotate => { + p.rect(9.0, 9.0, 15.0, 15.0, 0.8, t(B), B); + p.arc_arrow((12.0, 12.0), 8.5, -70.0, 220.0, K); + } + + // ----- Picture ----- + I::Crop => { + p.rline_w(&[(6.5, 2.0), (6.5, 17.5), (22.0, 17.5)], 0.6, K, 1.25); + p.rline_w(&[(2.0, 6.5), (17.5, 6.5), (17.5, 22.0)], 0.6, B, 1.25); + } + I::Corrections => { + p.poly(&arc_pts((12.0, 12.0), 5.2, 5.2, -90.0, 90.0), Y, N); + p.circle((12.0, 12.0), 5.2, N, K); + for i in 0..8 { + let (s, c) = rad(i as f32 * 45.0).sin_cos(); + p.seg((12.0 + c * 7.6, 12.0 + s * 7.6), (12.0 + c * 9.8, 12.0 + s * 9.8), Y); + } + } + I::ColorAdjust => { + p.circle((12.0, 8.0), 5.6, R.a(0.6), N); + p.circle((8.3, 14.6), 5.6, G.a(0.6), N); + p.circle((15.7, 14.6), 5.6, B.a(0.6), N); + } + I::ArtisticEffects => { + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + p.curve_w((5.5, 16.0), (8.0, 4.5), (14.0, 21.0), (18.5, 8.0), PU, 2.0); + } + I::Transparency => { + let cs = 13.0 / 4.0; + for i in 0..4 { + for j in 0..4 { + if (i + j) % 2 == 0 { + let (x, y) = (3.0 + i as f32 * cs, 3.0 + j as f32 * cs); + p.rect(x, y, x + cs, y + cs, 0.0, K.a(0.3), N); + } + } + } + p.rect(3.0, 3.0, 16.0, 16.0, 0.6, N, K.a(0.6)); + p.rect(9.0, 9.0, 21.0, 21.0, 1.2, B.a(0.55), B); + } + I::CompressPictures => { + p.picture(6.5, 6.0, 17.5, 18.0); + p.arrow((0.8, 12.0), (5.0, 12.0), K); + p.arrow((23.2, 12.0), (19.0, 12.0), K); + } + I::ChangePicture => { + p.picture(2.0, 3.0, 18.0, 15.5); + let c = (17.5, 17.5); + if p.dis { + p.circle(c, 5.0, N, G); + } else { + p.circle(c, 5.0, G, N); + } + let wc = if p.dis { K } else { W }; + p.arc_arrow(c, 2.8, 200.0, 330.0, wc); + p.arc_arrow(c, 2.8, 20.0, 150.0, wc); + } + I::ResetPicture => { + p.picture(2.0, 3.0, 18.0, 15.5); + p.arc_arrow((17.0, 17.5), 4.0, -10.0, -260.0, B); + } + I::RemoveBackground => { + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, PU.a(0.55), N); + p.dot((12.0, 10.0), 3.0, B); + p.poly(&arc_pts((12.0, 19.3), 6.0, 5.5, 180.0, 360.0), B, N); + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + } + I::AltText => { + p.picture(2.0, 2.0, 15.5, 12.5); + p.seg((2.5, 16.5), (21.5, 16.5), K); + p.seg((2.5, 20.5), (15.0, 20.5), K); + } + + // ----- Table ----- + I::InsertAbove | I::InsertBelow | I::InsertLeft | I::InsertRight => { + let (grid, add): ((f32, f32, f32, f32, usize, usize), (f32, f32, f32, f32)) = match icon { + I::InsertAbove => ((3.0, 10.0, 21.0, 21.0, 2, 3), (3.0, 2.5, 21.0, 7.5)), + I::InsertBelow => ((3.0, 3.0, 21.0, 14.0, 2, 3), (3.0, 16.5, 21.0, 21.5)), + I::InsertLeft => ((10.0, 3.0, 21.0, 21.0, 3, 2), (2.5, 3.0, 7.5, 21.0)), + _ => ((3.0, 3.0, 14.0, 21.0, 3, 2), (16.5, 3.0, 21.5, 21.0)), + }; + p.table(grid.0, grid.1, grid.2, grid.3, grid.4, grid.5, N); + p.rect(add.0, add.1, add.2, add.3, 1.0, t(G), G); + let c = ((add.0 + add.2) * 0.5, (add.1 + add.3) * 0.5); + p.seg_w((c.0 - 1.6, c.1), (c.0 + 1.6, c.1), G, 0.9); + p.seg_w((c.0, c.1 - 1.6), (c.0, c.1 + 1.6), G, 0.9); + } + I::DeleteTable => { + p.table(2.5, 3.0, 19.5, 17.0, 3, 3, B); + p.badge((18.0, 18.0), 5.0, R, Glyph::Cross); + } + I::MergeCells => { + p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.dashed(&[(12.0, 6.0), (12.0, 18.0)], K.a(0.6), 1.5, 1.2); + p.arrow((4.5, 12.0), (10.2, 12.0), B); + p.arrow((19.5, 12.0), (13.8, 12.0), B); + } + I::SplitCells => { + p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.seg((12.0, 6.0), (12.0, 18.0), K); + p.arrow((10.0, 12.0), (4.5, 12.0), B); + p.arrow((14.0, 12.0), (19.5, 12.0), B); + } + I::Borders => { + p.dotted(&[(12.0, 5.0), (12.0, 19.0)], K, 2.2, 0.45); + p.dotted(&[(5.0, 12.0), (19.0, 12.0)], K, 2.2, 0.45); + p.rect(3.0, 3.0, 21.0, 21.0, 0.6, N, B); + } + I::Shading => { + p.rect(3.0, 3.0, 12.0, 12.0, 0.0, Y.a(0.8), N); + p.rect(12.0, 12.0, 21.0, 21.0, 0.0, Y.a(0.8), N); + p.table(3.0, 3.0, 21.0, 21.0, 2, 2, N); + } + + // ----- Chart ----- + I::ChartElements => { + p.bars(2.5, 3.5, 16.0, 17.5); + p.badge((18.5, 18.5), 4.8, G, Glyph::Plus); + } + I::ChartStyles => { + p.bars(2.5, 6.0, 15.5, 20.5); + p.brush((14.0, 10.0), -45.0, 12.0, PU); + } + I::SwitchRowCol => { + p.table(2.5, 2.5, 11.0, 11.0, 2, 2, B); + p.rect(13.0, 13.0, 17.25, 21.5, 0.6, O, N); + p.table(13.0, 13.0, 21.5, 21.5, 2, 2, N); + p.line(&quad_pts((14.0, 4.0), (20.0, 4.0), (20.0, 9.5), 10), K); + p.head((20.0, 11.0), 90.0, 3.6, K); + p.line(&quad_pts((10.0, 20.0), (4.0, 20.0), (4.0, 14.5), 10), K); + p.head((4.0, 13.0), -90.0, 3.6, K); + } + I::EditData => { + p.table(2.5, 3.0, 18.0, 16.0, 3, 3, B); + p.tool((11.0, 21.5), -45.0, 14.0, 2.2, 3.8, Y, K, 2.5, R); + } + I::ChangeChartType => { + p.circle((8.0, 8.0), 5.8, t(B), B); + let mut wedge = vec![(8.0, 8.0)]; + wedge.extend(arc_pts((8.0, 8.0), 5.8, 5.8, -90.0, 0.0)); + p.poly(&wedge, O, N); + p.rect(13.0, 15.0, 15.8, 21.0, 0.4, G, N); + p.rect(17.5, 11.0, 20.3, 21.0, 0.4, B, N); + p.seg((11.5, 21.5), (22.0, 21.5), K); + } + + // ----- Media ----- + I::Play => p.rshape(&[(6.5, 4.0), (19.5, 12.0), (6.5, 20.0)], 1.0, G, N), + I::Pause => { + p.rect(6.0, 4.5, 10.0, 19.5, 1.0, K, N); + p.rect(14.0, 4.5, 18.0, 19.5, 1.0, K, N); + } + I::Stop => p.rect(5.0, 5.0, 19.0, 19.0, 2.0, K, N), + I::TrimMedia => { + p.rect(2.0, 8.0, 22.0, 16.0, 1.2, N, K); + p.rect(7.0, 8.0, 17.0, 16.0, 0.0, t(Y), N); + p.rect(5.8, 5.0, 8.2, 19.0, 0.8, Y, N); + p.rect(15.8, 5.0, 18.2, 19.0, 0.8, Y, N); + } + I::Volume => { + p.speaker(2.5, 12.0, N, K); + p.arc((11.0, 12.0), 3.8, -45.0, 45.0, B); + p.arc((11.0, 12.0), 7.0, -45.0, 45.0, B); + p.arc((11.0, 12.0), 10.2, -40.0, 40.0, B); + } + I::Loop => { + p.path_arrow(&round_pts(&[(4.0, 13.0), (4.0, 7.0), (20.0, 7.0)], 2.5, false), G); + p.path_arrow(&round_pts(&[(20.0, 11.0), (20.0, 17.0), (4.0, 17.0)], 2.5, false), G); + } + I::Bookmark => { + p.rshape(&[(6.0, 2.5), (18.0, 2.5), (18.0, 21.5), (12.0, 16.5), (6.0, 21.5)], 0.8, R, N); + } + + // ----- Window chrome ----- + I::Home => { + p.rect(10.0, 14.5, 14.0, 21.0, 0.8, B, N); + p.line(&[(2.5, 11.5), (12.0, 3.0), (21.5, 11.5)], K); + p.rline(&[(5.0, 9.5), (5.0, 21.0), (19.0, 21.0), (19.0, 9.5)], 1.2, K); + } + I::Save => { + p.rshape(&[(3.0, 3.0), (17.5, 3.0), (21.0, 6.5), (21.0, 21.0), (3.0, 21.0)], 1.5, t(B), B); + p.rect(7.0, 3.0, 15.0, 8.5, 0.6, N, B); + p.seg((12.5, 4.5), (12.5, 7.0), B); + p.rect(6.5, 13.0, 17.5, 21.0, 0.8, N, B); + } + I::Undo | I::Redo => { + let mut pts = vec![(6.0, 8.5), (14.0, 8.5)]; + pts.extend(arc_pts((14.0, 14.0), 5.5, 5.5, -90.0, 90.0)); + pts.push((8.5, 19.5)); + let hs = p.head_size(); + let pts: Vec = if icon == I::Undo { pts } else { pts.into_iter().map(|(x, y)| (24.0 - x, y)).collect() }; + p.line(&pts, K); + if icon == I::Undo { + p.head((3.0, 8.5), 180.0, hs, K); + } else { + p.head((21.0, 8.5), 0.0, hs, K); + } + } + I::More => { + for x in [5.0, 12.0, 19.0] { + p.dot((x, 12.0), 1.9, K); + } + } + I::Search => p.magnifier((10.0, 10.0), 6.5, N, K), + I::Share => { + p.seg((6.0, 12.0), (18.0, 5.5), K); + p.seg((6.0, 12.0), (18.0, 18.5), K); + p.dot((18.0, 5.5), 3.0, B); + p.dot((6.0, 12.0), 3.0, B); + p.dot((18.0, 18.5), 3.0, B); + } + I::CommentsBubble => p.bubble(2.5, 3.5, 21.5, 17.0, N, K), + I::File => { + p.page(4.5, 2.0, 19.5, 22.0, N, K); + p.seg((8.0, 12.5), (16.0, 12.5), B); + p.seg((8.0, 16.5), (14.0, 16.5), B); + } + I::Folder => { + p.rshape(&[(2.5, 4.5), (9.0, 4.5), (11.0, 7.0), (21.5, 7.0), (21.5, 19.5), (2.5, 19.5)], 1.2, t(Y), K); + p.rshape(&[(2.5, 10.0), (21.5, 10.0), (21.5, 19.5), (2.5, 19.5)], 1.2, Y.a(0.6), K); + } + I::Export => { + p.rline(&[(15.0, 8.5), (15.0, 2.5), (3.0, 2.5), (3.0, 21.5), (15.0, 21.5), (15.0, 15.5)], 1.5, K); + p.arrow((8.5, 12.0), (22.0, 12.0), B); + } + I::Print => { + p.rect(6.5, 2.5, 17.5, 9.0, 0.8, N, K); + p.rline(&[(6.5, 17.5), (2.5, 17.5), (2.5, 9.0), (21.5, 9.0), (21.5, 17.5), (17.5, 17.5)], 1.8, K); + p.rect(6.5, 14.0, 17.5, 21.5, 0.8, N, K); + p.dot((18.5, 12.0), 1.1, G); + } + I::Settings => { + p.gear((12.0, 12.0), 9.8, 7.3, 8, N, K); + p.circle((12.0, 12.0), 3.0, N, K); + } + I::Help => { + p.circle((12.0, 12.0), 9.8, N, K); + let mut q = arc_pts((12.0, 9.4), 3.2, 3.0, 180.0, 400.0); + q.push((12.0, 13.0)); + q.push((12.0, 14.6)); + p.line(&q, B); + p.dot((12.0, 17.8), 1.25, B); + } + I::Info => { + p.circle((12.0, 12.0), 9.8, N, K); + p.dot((12.0, 7.3), 1.3, B); + p.seg((12.0, 10.8), (12.0, 17.5), B); + } + I::Warning => { + p.rshape(&[(12.0, 2.5), (22.5, 20.5), (1.5, 20.5)], 2.0, Y, N); + p.seg((12.0, 9.0), (12.0, 14.0), D); + p.dot((12.0, 17.2), 1.2, D); + } + // Selection pane toggles: monochrome outlines in the ink colour. + I::Lock => { + let mut sh = vec![(7.5, 10.5), (7.5, 8.0)]; + sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); + sh.push((16.5, 10.5)); + p.line(&sh, K); + p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); + p.seg((12.0, 14.5), (12.0, 17.5), K); + } + I::Unlock => { + // The shackle swung open: its left leg out of the body, raised. + let mut sh = vec![(12.0, 10.5), (12.0, 6.0)]; + sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); + sh.push((21.0, 8.5)); + p.line(&sh, K); + p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); + p.seg((9.75, 14.5), (9.75, 17.5), K); + } + I::Eye | I::EyeOff => { + let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16); + e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1)); + e.pop(); + p.shape(&e, N, K); + p.circle((12.0, 12.0), 3.2, N, K); + if icon == I::EyeOff { + p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1); + } + } + I::Close => { + p.seg_w((5.5, 5.5), (18.5, 18.5), K, 1.1); + p.seg_w((18.5, 5.5), (5.5, 18.5), K, 1.1); + } + I::Check => p.line_w(&[(4.0, 12.5), (9.5, 18.0), (20.0, 6.5)], G, 1.3), + I::Plus => { + p.seg((12.0, 4.5), (12.0, 19.5), K); + p.seg((4.5, 12.0), (19.5, 12.0), K); + } + I::Minus => p.seg((4.5, 12.0), (19.5, 12.0), K), + I::ChevronDown => p.line(&[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)], K), + I::ChevronUp => p.line(&[(6.0, 15.0), (12.0, 9.0), (18.0, 15.0)], K), + I::ChevronLeft => p.line(&[(15.0, 6.0), (9.0, 12.0), (15.0, 18.0)], K), + I::ChevronRight => p.line(&[(9.0, 6.0), (15.0, 12.0), (9.0, 18.0)], K), + I::DragHandle => { + for x in [9.0, 15.0] { + for y in [6.0, 12.0, 18.0] { + p.dot((x, y), 1.6, K); + } + } + } + I::Star => p.star((12.0, 12.8), 10.0, t(Y), Y), + I::Image => { + p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.circle((16.0, 9.0), 1.8, N, K); + p.line(&[(2.5, 17.5), (8.5, 11.0), (14.0, 16.5), (16.5, 14.0), (21.5, 18.5)], K); + } + I::Sparkle => { + p.sparkle((10.5, 13.0), 9.0, PU); + p.sparkle((19.0, 5.0), 3.8, Y); + } + I::Palette => { + let mut pts = arc_pts((12.0, 12.0), 9.8, 9.5, 70.0, 380.0); + pts.extend(cubic_pts((21.2, 15.25), (19.0, 15.0), (14.5, 14.0), (15.5, 21.0), 10)); + p.shape(&pts, N, K); + p.dot((7.5, 9.5), 1.7, R); + p.dot((11.5, 6.3), 1.7, Y); + p.dot((16.3, 7.8), 1.7, G); + p.dot((7.2, 14.8), 1.7, B); + } + I::Grid => { + for (x, y) in [(3.0, 3.0), (13.0, 3.0), (3.0, 13.0), (13.0, 13.0)] { + p.rect(x, y, x + 8.0, y + 8.0, 1.5, N, K); + } + } + I::List => { + for y in [5.5, 12.0, 18.5] { + p.dot((4.5, y), 1.5, K); + p.seg((9.0, y), (21.0, y), K); + } + } + I::Collapse => { + p.line(&[(7.0, 3.0), (12.0, 8.0), (17.0, 3.0)], K); + p.line(&[(7.0, 21.0), (12.0, 16.0), (17.0, 21.0)], K); + p.seg_w((5.0, 12.0), (19.0, 12.0), K.a(0.5), 0.8); + } + I::Expand => { + p.line(&[(7.0, 8.0), (12.0, 3.0), (17.0, 8.0)], K); + p.line(&[(7.0, 16.0), (12.0, 21.0), (17.0, 16.0)], K); + p.seg_w((5.0, 12.0), (19.0, 12.0), K.a(0.5), 0.8); + } + I::Pin => { + let m = |pts: &[P2]| tf((12.0, 12.0), 35.0, pts); + p.line(&m(&[(0.0, 2.0), (0.0, 10.5)]), K); + p.rshape(&m(&[(-6.0, 0.0), (6.0, 0.0), (6.0, 2.5), (-6.0, 2.5)]), 0.6, R, N); + p.poly(&m(&[(-3.0, -7.0), (3.0, -7.0), (3.6, 0.0), (-3.6, 0.0)]), R, N); + p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N); + } + + // ----- File tab (Backstage) navigation: outlines in the ink colour ----- + I::BackCircle => { + p.circle((12.0, 12.0), 9.8, N, K); + p.arrow((17.0, 12.0), (6.5, 12.0), K); + } + I::SaveAs => { + p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K); + p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K); + p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4); + p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0); + } + I::Account => { + p.circle((12.0, 8.0), 4.5, N, K); + p.line(&arc_pts((12.0, 22.0), 8.5, 7.5, 180.0, 360.0), K); + } + I::Recent => { + p.circle((12.0, 12.0), 9.8, N, K); + p.line(&[(12.0, 6.0), (12.0, 12.0), (16.0, 14.5)], K); + } + I::NewFile => { + p.rline(&[(14.0, 2.5), (5.0, 2.5), (5.0, 21.5), (19.0, 21.5), (19.0, 7.5), (14.0, 2.5), (14.0, 7.5), (19.0, 7.5)], 0.8, K); + } + I::OpenFolder => { + p.rline(&[(18.5, 9.0), (18.5, 6.5), (10.0, 6.5), (8.0, 4.0), (2.5, 4.0), (2.5, 19.5), (18.0, 19.5)], 1.0, K); + p.rline(&[(2.5, 19.5), (6.0, 10.0), (22.0, 10.0), (18.0, 19.5)], 1.0, K); + } + + // ----- Status bar (monochrome) ----- + I::NotesSmall => { + p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K); + p.seg((3.0, 13.5), (21.0, 13.5), K); + p.seg((6.5, 16.8), (15.5, 16.8), K); + } + I::CommentsSmall => p.bubble(3.0, 4.0, 21.0, 16.5, N, K), + I::ViewNormalSmall => { + p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.seg((8.5, 4.0), (8.5, 20.0), K); + } + I::ViewSorterSmall => { + for (x, y) in [(2.5, 4.5), (13.0, 4.5), (2.5, 13.5), (13.0, 13.5)] { + p.rect(x, y, x + 8.5, y + 6.0, 1.0, N, K); + } + } + I::ViewReadingSmall => { + p.book(N, K); + p.seg((12.0, 7.0), (12.0, 20.5), K); + } + I::ViewShowSmall => { + p.seg((2.0, 4.0), (22.0, 4.0), K); + p.rect(3.5, 4.0, 20.5, 16.0, 0.8, N, K); + p.seg((12.0, 16.0), (12.0, 19.0), K); + p.line(&[(8.5, 22.0), (12.0, 19.0), (15.5, 22.0)], K); + } + I::FitSmall => { + for (cx, cy, sx, sy) in [(3.0, 3.0, 1.0, 1.0), (21.0, 3.0, -1.0, 1.0), (3.0, 21.0, 1.0, -1.0), (21.0, 21.0, -1.0, -1.0)] { + p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); + } + p.rect(8.0, 8.5, 16.0, 15.5, 0.8, N, K); + } + I::ZoomIn | I::ZoomOut => { + p.magnifier((10.0, 10.0), 7.0, N, K); + p.seg((7.0, 10.0), (13.0, 10.0), K); + if icon == I::ZoomIn { + p.seg((10.0, 7.0), (10.0, 13.0), K); + } + } + } +} diff --git a/crates/ui-egui/src/icons/geometry.rs b/crates/ui-egui/src/icons/geometry.rs new file mode 100644 index 000000000..93ac96c05 --- /dev/null +++ b/crates/ui-egui/src/icons/geometry.rs @@ -0,0 +1,763 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Drawing toolkit +// --------------------------------------------------------------------------------------------- + +/// A logical colour, resolved against the theme ink and the disabled flag. +#[derive(Clone, Copy)] +pub(super) enum C { + Ink(f32), + Acc(Color32, f32), + White, + None, +} + +impl C { + pub(super) fn a(self, f: f32) -> C { + match self { + C::Ink(a) => C::Ink(a * f), + C::Acc(c, a) => C::Acc(c, a * f), + other => other, + } + } +} + +pub(super) const K: C = C::Ink(1.0); + +pub(super) const B: C = C::Acc(BLUE, 1.0); + +pub(super) const O: C = C::Acc(ORANGE, 1.0); + +pub(super) const G: C = C::Acc(GREEN, 1.0); + +pub(super) const PU: C = C::Acc(PURPLE, 1.0); + +pub(super) const R: C = C::Acc(RED, 1.0); + +pub(super) const Y: C = C::Acc(GOLD, 1.0); + +pub(super) const D: C = C::Acc(DARK, 1.0); + +pub(super) const W: C = C::White; + +pub(super) const N: C = C::None; + +/// Light tint of an accent, for fills behind outlines. +pub(super) fn t(c: C) -> C { + c.a(0.25) +} + +pub(super) type P2 = (f32, f32); + +#[derive(Clone, Copy)] +pub(super) enum Glyph { + Plus, + Cross, + Check, +} + +pub(super) struct Pen<'a> { + pub(super) p: &'a Painter, + pub(super) o: Pos2, + /// Points per design unit. + pub(super) k: f32, + /// Base stroke width in points. + pub(super) sw: f32, + pub(super) ink: Color32, + pub(super) dis: bool, +} + +pub(super) fn rad(deg: f32) -> f32 { + deg * PI / 180.0 +} + +pub(super) fn arc_pts(c: P2, rx: f32, ry: f32, a0: f32, a1: f32) -> Vec { + let n = (((a1 - a0).abs() / 9.0).ceil() as usize).max(4); + (0..=n) + .map(|i| { + let a = rad(a0 + (a1 - a0) * i as f32 / n as f32); + (c.0 + rx * a.cos(), c.1 + ry * a.sin()) + }) + .collect() +} + +pub(super) fn cubic_pts(p0: P2, p1: P2, p2: P2, p3: P2, n: usize) -> Vec { + (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + (a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1) + }) + .collect() +} + +pub(super) fn quad_pts(p0: P2, p1: P2, p2: P2, n: usize) -> Vec { + (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + let u = 1.0 - t; + (u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1) + }) + .collect() +} + +/// Rounds the corners of a polyline by `r` design units. +pub(super) fn round_pts(pts: &[P2], r: f32, closed: bool) -> Vec { + let n = pts.len(); + if n < 3 || r <= 0.0 { + return pts.to_vec(); + } + let mut out = Vec::with_capacity(n * 6); + for i in 0..n { + let cur = pts[i]; + let interior = closed || (i > 0 && i + 1 < n); + if !interior { + out.push(cur); + continue; + } + let prev = pts[(i + n - 1) % n]; + let next = pts[(i + 1) % n]; + let d1 = ((prev.0 - cur.0).powi(2) + (prev.1 - cur.1).powi(2)).sqrt(); + let d2 = ((next.0 - cur.0).powi(2) + (next.1 - cur.1).powi(2)).sqrt(); + if d1 < 1e-4 || d2 < 1e-4 { + out.push(cur); + continue; + } + let rr = r.min(d1 * 0.5).min(d2 * 0.5); + let a = (cur.0 + (prev.0 - cur.0) / d1 * rr, cur.1 + (prev.1 - cur.1) / d1 * rr); + let b = (cur.0 + (next.0 - cur.0) / d2 * rr, cur.1 + (next.1 - cur.1) / d2 * rr); + out.extend(quad_pts(a, cur, b, 4)); + } + out +} + +pub(super) fn rrect_pts(x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> Vec { + round_pts(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)], r, true) +} + +/// Star polygon with `n` points. +pub(super) fn star_pts(c: P2, ro: f32, ri: f32, n: usize, rot: f32) -> Vec { + (0..n * 2) + .map(|i| { + let r = if i % 2 == 0 { ro } else { ri }; + let a = rad(rot - 90.0 + i as f32 * 180.0 / n as f32); + (c.0 + r * a.cos(), c.1 + r * a.sin()) + }) + .collect() +} + +/// Transforms local points (u along `ang`, v across) to design coordinates around `c`. +pub(super) fn tf(c: P2, ang: f32, pts: &[P2]) -> Vec { + let (s, co) = rad(ang).sin_cos(); + pts.iter().map(|&(u, v)| (c.0 + u * co - v * s, c.1 + u * s + v * co)).collect() +} + +fn point_in_tri(p: Pos2, a: Pos2, b: Pos2, c: Pos2) -> bool { + let d1 = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y); + let d2 = (p.x - c.x) * (b.y - c.y) - (b.x - c.x) * (p.y - c.y); + let d3 = (p.x - a.x) * (c.y - a.y) - (c.x - a.x) * (p.y - a.y); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) +} + +/// Ear-clipping triangulation of a simple polygon. +pub(super) fn triangulate(pts: &[Pos2]) -> Vec<[u32; 3]> { + let n = pts.len(); + let mut tris = Vec::new(); + if n < 3 { + return tris; + } + let area: f32 = (0..n) + .map(|i| { + let a = pts[i]; + let b = pts[(i + 1) % n]; + a.x * b.y - b.x * a.y + }) + .sum(); + let mut idx: Vec = (0..n).collect(); + if area < 0.0 { + idx.reverse(); + } + while idx.len() > 3 { + let m = idx.len(); + let mut clipped = false; + for i in 0..m { + let (ia, ib, ic) = (idx[(i + m - 1) % m], idx[i], idx[(i + 1) % m]); + let (a, b, c) = (pts[ia], pts[ib], pts[ic]); + let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if cross <= 1e-7 { + continue; + } + let blocked = idx.iter().any(|&j| j != ia && j != ib && j != ic && point_in_tri(pts[j], a, b, c)); + if blocked { + continue; + } + tris.push([ia as u32, ib as u32, ic as u32]); + idx.remove(i); + clipped = true; + break; + } + if !clipped { + // Degenerate input: drop a vertex and carry on rather than loop forever. + idx.remove(0); + } + } + if let [a, b, c] = idx[..] { + tris.push([a as u32, b as u32, c as u32]); + } + tris +} + +impl Pen<'_> { + fn pt(&self, (x, y): P2) -> Pos2 { + pos2(self.o.x + x * self.k, self.o.y + y * self.k) + } + + fn v(&self, pts: &[P2]) -> Vec { + pts.iter().map(|&p| self.pt(p)).collect() + } + + fn col(&self, c: C) -> Color32 { + let dim = if self.dis { 0.35 } else { 1.0 }; + match c { + C::Ink(a) => self.ink.gamma_multiply(a * dim), + C::Acc(col, a) => { + if self.dis { + self.ink.gamma_multiply(a * dim) + } else { + col.gamma_multiply(a) + } + } + C::White => { + if self.dis { + // Glyphs on solid fills stay legible as a darker mark. + self.ink.gamma_multiply(0.6) + } else { + Color32::WHITE + } + } + C::None => Color32::TRANSPARENT, + } + } + + fn stroke(&self, c: C, w: f32) -> Stroke { + Stroke::new(self.sw * w, self.col(c)) + } + + // --- lines --- + + pub(super) fn line_w(&self, pts: &[P2], c: C, w: f32) { + if pts.len() < 2 { + return; + } + let col = self.col(c); + if col == Color32::TRANSPARENT { + return; + } + let v = self.v(pts); + if let [a, b] = v[..] { + // Single segments get round caps: draw them as one convex capsule. + let d = b - a; + let len = d.length(); + let r = self.sw * w * 0.5; + if len < 1e-3 { + self.p.circle_filled(a, r, col); + return; + } + let ang = d.y.atan2(d.x); + let mut cap = Vec::with_capacity(20); + for (c, start) in [(b, ang - PI * 0.5), (a, ang + PI * 0.5)] { + for i in 0..=8 { + let t = start + PI * i as f32 / 8.0; + cap.push(c + vec2(t.cos(), t.sin()) * r); + } + } + self.p.add(PathShape::convex_polygon(cap, col, Stroke::NONE)); + return; + } + self.p.add(PathShape::line(v, Stroke::new(self.sw * w, col))); + } + + pub(super) fn line(&self, pts: &[P2], c: C) { + self.line_w(pts, c, 1.0); + } + + pub(super) fn seg(&self, a: P2, b: P2, c: C) { + self.line_w(&[a, b], c, 1.0); + } + + pub(super) fn seg_w(&self, a: P2, b: P2, c: C, w: f32) { + self.line_w(&[a, b], c, w); + } + + pub(super) fn rline(&self, pts: &[P2], r: f32, c: C) { + self.line(&round_pts(pts, r, false), c); + } + + pub(super) fn rline_w(&self, pts: &[P2], r: f32, c: C, w: f32) { + self.line_w(&round_pts(pts, r, false), c, w); + } + + pub(super) fn dashed(&self, pts: &[P2], c: C, dash: f32, gap: f32) { + let v = self.v(pts); + self.p.extend(Shape::dashed_line(&v, self.stroke(c, 1.0), dash * self.k, gap * self.k)); + } + + pub(super) fn dashed_w(&self, pts: &[P2], c: C, dash: f32, gap: f32, w: f32) { + let v = self.v(pts); + self.p.extend(Shape::dashed_line(&v, self.stroke(c, w), dash * self.k, gap * self.k)); + } + + pub(super) fn dotted(&self, pts: &[P2], c: C, spacing: f32, r: f32) { + let v = self.v(pts); + self.p.extend(Shape::dotted_line(&v, self.col(c), spacing * self.k, r * self.sw)); + } + + // --- filled shapes --- + + /// Any simple polygon: filled via triangulation, stroked as a closed path. + pub(super) fn shape_w(&self, pts: &[P2], fill: C, stroke: C, w: f32) { + if pts.len() < 3 { + return; + } + let v = self.v(pts); + let fc = self.col(fill); + if fc != Color32::TRANSPARENT { + let mut mesh = Mesh::default(); + for &p in &v { + mesh.colored_vertex(p, fc); + } + for [a, b, c] in triangulate(&v) { + mesh.add_triangle(a, b, c); + } + self.p.add(Shape::mesh(mesh)); + // A hairline in the fill colour anti-aliases the mesh edge. + if matches!(stroke, C::None) { + self.p.add(PathShape::closed_line(v.clone(), Stroke::new(0.6_f32.min(self.sw * 0.5), fc))); + } + } + let sc = self.col(stroke); + if sc != Color32::TRANSPARENT { + self.p.add(PathShape::closed_line(v, Stroke::new(self.sw * w, sc))); + } + } + + pub(super) fn shape(&self, pts: &[P2], fill: C, stroke: C) { + self.shape_w(pts, fill, stroke, 1.0); + } + + pub(super) fn rshape(&self, pts: &[P2], r: f32, fill: C, stroke: C) { + self.shape(&round_pts(pts, r, true), fill, stroke); + } + + /// Convex polygon (anti-aliased fill). + pub(super) fn poly(&self, pts: &[P2], fill: C, stroke: C) { + self.poly_w(pts, fill, stroke, 1.0); + } + + pub(super) fn poly_w(&self, pts: &[P2], fill: C, stroke: C, w: f32) { + let v = self.v(pts); + self.p.add(PathShape::convex_polygon(v, self.col(fill), self.stroke(stroke, w))); + } + + fn rect_w(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C, w: f32) { + let rect = Rect::from_min_max(self.pt((x0, y0)), self.pt((x1, y1))); + self.p.rect(rect, r * self.k, self.col(fill), self.stroke(stroke, w), StrokeKind::Middle); + } + + pub(super) fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C) { + self.rect_w(x0, y0, x1, y1, r, fill, stroke, 1.0); + } + + fn circle_w(&self, c: P2, r: f32, fill: C, stroke: C, w: f32) { + self.p.circle(self.pt(c), r * self.k, self.col(fill), self.stroke(stroke, w)); + } + + pub(super) fn circle(&self, c: P2, r: f32, fill: C, stroke: C) { + self.circle_w(c, r, fill, stroke, 1.0); + } + + pub(super) fn dot(&self, c: P2, r: f32, fill: C) { + self.p.circle_filled(self.pt(c), r * self.k, self.col(fill)); + } + + pub(super) fn ellipse(&self, c: P2, rx: f32, ry: f32, fill: C, stroke: C) { + let mut pts = arc_pts(c, rx, ry, 0.0, 360.0); + pts.pop(); + self.poly(&pts, fill, stroke); + } + + // --- arcs and arrows --- + + pub(super) fn arc(&self, c: P2, r: f32, a0: f32, a1: f32, col: C) { + self.line(&arc_pts(c, r, r, a0, a1), col); + } + + /// Filled arrow head with its tip at `tip`, pointing along `dir` degrees. + pub(super) fn head(&self, tip: P2, dir: f32, size: f32, c: C) { + let (s, co) = rad(dir).sin_cos(); + let hw = size * 0.62; + let back = (tip.0 - co * size, tip.1 - s * size); + self.poly(&[tip, (back.0 - s * hw, back.1 + co * hw), (back.0 + s * hw, back.1 - co * hw)], c, N); + } + + pub(super) fn head_size(&self) -> f32 { + (2.2 * self.sw / self.k).max(3.6) + } + + pub(super) fn arrow(&self, a: P2, b: P2, c: C) { + let dir = (b.1 - a.1).atan2(b.0 - a.0); + let hs = self.head_size(); + let end = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); + self.seg(a, end, c); + self.head(b, dir.to_degrees(), hs, c); + } + + pub(super) fn arrow2(&self, a: P2, b: P2, c: C) { + let dir = (b.1 - a.1).atan2(b.0 - a.0); + let hs = self.head_size(); + let s = (a.0 + dir.cos() * hs * 0.7, a.1 + dir.sin() * hs * 0.7); + let e = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); + self.seg(s, e, c); + self.head(b, dir.to_degrees(), hs, c); + self.head(a, dir.to_degrees() + 180.0, hs, c); + } + + /// Polyline ending in an arrow head. + pub(super) fn path_arrow(&self, pts: &[P2], c: C) { + let n = pts.len(); + if n < 2 { + return; + } + let a = pts[n - 2]; + let b = pts[n - 1]; + let dir = (b.1 - a.1).atan2(b.0 - a.0); + let hs = self.head_size(); + let mut body = pts.to_vec(); + body[n - 1] = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); + self.line(&body, c); + self.head(b, dir.to_degrees(), hs, c); + } + + /// Circular arc from `a0` to `a1` degrees with an arrow head at the `a1` end. + pub(super) fn arc_arrow(&self, c: P2, r: f32, a0: f32, a1: f32, col: C) { + let hs = self.head_size(); + let sign = if a1 >= a0 { 1.0 } else { -1.0 }; + let back = (hs * 0.6 / r).to_degrees() * sign; + self.line(&arc_pts(c, r, r, a0, a1 - back), col); + let tip_a = rad(a1); + let tip = (c.0 + r * tip_a.cos(), c.1 + r * tip_a.sin()); + // Tangent direction of travel, nudged inward to sit on the arc. + let dir = a1 + 90.0 * sign - (hs * 0.5 / r).to_degrees() * sign; + self.head(tip, dir, hs, col); + } + + pub(super) fn curve(&self, p0: P2, p1: P2, p2: P2, p3: P2, c: C) { + self.line(&cubic_pts(p0, p1, p2, p3, 24), c); + } + + pub(super) fn curve_w(&self, p0: P2, p1: P2, p2: P2, p3: P2, c: C, w: f32) { + self.line_w(&cubic_pts(p0, p1, p2, p3, 24), c, w); + } + + // --- text --- + + pub(super) fn text(&self, s: &str, at: P2, size: f32, c: C) { + self.p.text(self.pt(at), Align2::CENTER_CENTER, s, FontId::proportional(size * self.k), self.col(c)); + } + + pub(super) fn text_bold(&self, s: &str, at: P2, size: f32, c: C) { + let d = 0.32 * self.sw / self.k; + let reps: &[f32] = if self.dis { &[0.0] } else { &[-d, 0.0, d] }; + for &dx in reps { + self.text(s, (at.0 + dx, at.1), size, c); + } + } + + pub(super) fn text_rot(&self, s: &str, at: P2, size: f32, c: C, angle_deg: f32) { + let col = self.col(c); + let galley = self.p.layout_no_wrap(s.to_owned(), FontId::proportional(size * self.k), col); + let rot = egui::emath::Rot2::from_angle(rad(angle_deg)); + let pos = self.pt(at) - rot * (galley.size() * 0.5); + self.p.add(TextShape::new(pos, galley, col).with_angle(rad(angle_deg))); + } + + // --- composite parts --- + + /// Round badge with a white glyph (outline + ink glyph when disabled). + pub(super) fn badge(&self, c: P2, r: f32, fill: C, glyph: Glyph) { + let gc = if self.dis { + self.circle(c, r, N, fill); + K + } else { + self.circle(c, r, fill, N); + W + }; + let g = r * 0.5; + let w = 0.95; + match glyph { + Glyph::Plus => { + self.seg_w((c.0 - g, c.1), (c.0 + g, c.1), gc, w); + self.seg_w((c.0, c.1 - g), (c.0, c.1 + g), gc, w); + } + Glyph::Cross => { + let g = g * 0.85; + self.seg_w((c.0 - g, c.1 - g), (c.0 + g, c.1 + g), gc, w); + self.seg_w((c.0 + g, c.1 - g), (c.0 - g, c.1 + g), gc, w); + } + Glyph::Check => { + self.line_w(&[(c.0 - g, c.1 + 0.1), (c.0 - g * 0.25, c.1 + g * 0.75), (c.0 + g, c.1 - g * 0.6)], gc, w); + } + } + } + + /// Landscape slide frame. + pub(super) fn frame(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.rect(x0, y0, x1, y1, 1.5, N, K); + } + + /// Document page with a folded top-right corner. + pub(super) fn page(&self, x0: f32, y0: f32, x1: f32, y1: f32, fill: C, stroke: C) { + let f = ((x1 - x0) * 0.32).min(5.0); + self.rshape(&[(x0, y0), (x1 - f, y0), (x1, y0 + f), (x1, y1), (x0, y1)], 1.2, fill, stroke); + self.line(&[(x1 - f, y0), (x1 - f, y0 + f), (x1, y0 + f)], stroke); + } + + /// Picture frame with sun and hills. + pub(super) fn picture(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + let (w, h) = (x1 - x0, y1 - y0); + let m = w.min(h); + self.rect(x0, y0, x1, y1, 1.5, N, K); + self.dot((x1 - w * 0.27, y0 + h * 0.3), m * 0.12, Y); + let base = y1 - h * 0.14; + self.poly(&[(x0 + w * 0.12, base), (x0 + w * 0.38, y0 + h * 0.36), (x0 + w * 0.64, base)], G, N); + self.poly(&[(x0 + w * 0.48, base), (x0 + w * 0.68, y0 + h * 0.56), (x1 - w * 0.12, base)], G.a(0.7), N); + } + + pub(super) fn magnifier(&self, c: P2, r: f32, lens_fill: C, col: C) { + self.circle(c, r, lens_fill, col); + let d = r * 0.707; + self.seg_w((c.0 + d + 0.6, c.1 + d + 0.6), (c.0 + r * 1.55, c.1 + r * 1.55), col, 1.5); + } + + fn bubble_pts(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { + let r = 2.5; + let mut pts = arc_pts((x0 + r, y0 + r), r, r, 180.0, 270.0); + pts.extend(arc_pts((x1 - r, y0 + r), r, r, 270.0, 360.0)); + pts.extend(arc_pts((x1 - r, y1 - r), r, r, 0.0, 90.0)); + let tw = ((x1 - x0) * 0.22).clamp(2.5, 4.5); + pts.push((x0 + r + 0.8 + tw, y1)); + pts.push((x0 + r - 0.2, y1 + 3.8)); + pts.push((x0 + r + 0.8, y1)); + pts.extend(arc_pts((x0 + r, y1 - r), r, r, 90.0, 180.0)); + pts + } + + pub(super) fn bubble(&self, x0: f32, y0: f32, x1: f32, y1: f32, fill: C, stroke: C) { + self.shape(&Self::bubble_pts(x0, y0, x1, y1), fill, stroke); + } + + pub(super) fn person(&self, cx: f32, top: f32, s: f32, fill: C) { + self.dot((cx, top + 2.6 * s), 2.6 * s, fill); + let base = top + 12.0 * s; + self.poly(&arc_pts((cx, base), 5.2 * s, 5.0 * s, 180.0, 360.0), fill, N); + } + + pub(super) fn star(&self, c: P2, ro: f32, fill: C, stroke: C) { + self.shape(&star_pts(c, ro, ro * 0.47, 5, 0.0), fill, stroke); + } + + pub(super) fn sparkle(&self, c: P2, r: f32, fill: C) { + self.shape(&star_pts(c, r, r * 0.3, 4, 0.0), fill, N); + } + + pub(super) fn gear(&self, c: P2, ro: f32, ri: f32, teeth: usize, fill: C, stroke: C) { + let step = 360.0 / teeth as f32; + let mut pts = Vec::with_capacity(teeth * 4); + for i in 0..teeth { + let base = i as f32 * step; + for (a, r) in [(-0.3, ri), (-0.17, ro), (0.17, ro), (0.3, ri)] { + let ang = rad(base + a * step); + pts.push((c.0 + r * ang.cos(), c.1 + r * ang.sin())); + } + } + self.shape(&pts, fill, stroke); + } + + pub(super) fn speaker(&self, x: f32, cy: f32, fill: C, stroke: C) { + self.rshape( + &[(x, cy - 3.0), (x + 3.8, cy - 3.0), (x + 8.5, cy - 7.5), (x + 8.5, cy + 7.5), (x + 3.8, cy + 3.0), (x, cy + 3.0)], + 0.8, + fill, + stroke, + ); + } + + /// Table grid; `header` fills the first row. + pub(super) fn table(&self, x0: f32, y0: f32, x1: f32, y1: f32, rows: usize, cols: usize, header: C) { + let rh = (y1 - y0) / rows as f32; + let cw = (x1 - x0) / cols as f32; + if !matches!(header, C::None) { + self.rect(x0, y0, x1, y0 + rh, 1.2, header, N); + } + for i in 1..rows { + let y = y0 + rh * i as f32; + self.seg_w((x0, y), (x1, y), K, 0.8); + } + for j in 1..cols { + let x = x0 + cw * j as f32; + self.seg_w((x, y0), (x, y1), K, 0.8); + } + self.rect(x0, y0, x1, y1, 1.2, N, K); + } + + /// Bar chart in the box. + pub(super) fn bars(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + let w = (x1 - x0) / 3.0; + let h = y1 - y0; + for (i, (frac, c)) in [(0.5, B), (1.0, O), (0.72, G)].into_iter().enumerate() { + let bx = x0 + w * i as f32; + self.rect(bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1, 0.6, c, N); + } + self.seg((x0 - 0.5, y1 + 0.2), (x1 + 0.5, y1 + 0.2), K); + } + + /// A writing tool lying along `ang` degrees with its point at `tip`. + pub(super) fn tool(&self, tip: P2, ang: f32, len: f32, hw: f32, cone: f32, body: C, lead: C, cap_len: f32, cap: C) { + let m = |pts: &[P2]| tf(tip, ang, pts); + self.poly(&m(&[(0.0, 0.0), (cone, -hw), (cone, hw)]), N, K); + self.poly(&m(&[(0.0, 0.0), (cone * 0.42, -hw * 0.42), (cone * 0.42, hw * 0.42)]), lead, N); + self.poly(&m(&[(cone, -hw), (len - cap_len, -hw), (len - cap_len, hw), (cone, hw)]), body, K); + if cap_len > 0.0 { + self.poly(&m(&[(len - cap_len, -hw), (len, -hw), (len, hw), (len - cap_len, hw)]), cap, K); + } + } + + /// Paint brush lying along `ang` with bristle tip at `tip`. + pub(super) fn brush(&self, tip: P2, ang: f32, len: f32, bristle: C) { + let m = |pts: &[P2]| tf(tip, ang, pts); + self.shape(&m(&[(0.0, -0.4), (2.5, -2.2), (5.5, -2.2), (5.5, 2.2), (2.5, 2.2), (0.0, 0.4)]), bristle, K); + self.poly(&m(&[(5.5, -2.0), (7.8, -2.0), (7.8, 2.0), (5.5, 2.0)]), K, K); + self.line(&m(&[(7.8, -1.1), (len, -1.3), (len, 1.3), (7.8, 1.1)]), K); + } + + /// Small path-drawn digit 1–3 in a `3 × h` box. + pub(super) fn digit(&self, d: u8, x: f32, y: f32, h: f32, c: C) { + let w = 0.8; + match d { + 1 => self.line_w(&[(x + 0.4, y + 1.2), (x + 2.0, y), (x + 2.0, y + h)], c, w), + 2 => self.line_w(&[(x, y + 1.0), (x + 0.7, y), (x + 2.4, y), (x + 3.1, y + 1.1), (x + 2.9, y + 2.2), (x, y + h), (x + 3.2, y + h)], c, w), + _ => self.line_w( + &[(x, y), (x + 3.0, y), (x + 1.3, y + h * 0.42), (x + 2.6, y + h * 0.5), (x + 3.1, y + h * 0.7), (x + 2.5, y + h), (x, y + h)], + c, + w, + ), + } + } + + /// Path-drawn capital A. + pub(super) fn letter_a(&self, x0: f32, top: f32, x1: f32, bottom: f32, c: C, w: f32) { + let mx = (x0 + x1) * 0.5; + self.line_w(&[(x0, bottom), (mx, top), (x1, bottom)], c, w); + let f = 0.62; + let y = top + (bottom - top) * f; + let dx = (x1 - x0) * 0.5 * f; + self.seg_w((mx - dx, y), (mx + dx, y), c, w); + } + + /// Hand-drawn scribble in the box. + pub(super) fn scribble(&self, x0: f32, y0: f32, x1: f32, y1: f32, c: C) { + let (w, h) = (x1 - x0, y1 - y0); + let p = |u: f32, v: f32| (x0 + w * u, y0 + h * v); + // A loopy cursive stroke (prolate cycloid), normalised into the box. + let raw: Vec = (0..=48) + .map(|i| { + let th = i as f32 / 48.0 * 4.0 * PI + 0.6; + (th - 1.9 * th.sin(), -1.9 * th.cos()) + }) + .collect(); + let (mut lx, mut hx, mut ly, mut hy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN); + for &(x, y) in &raw { + lx = lx.min(x); + hx = hx.max(x); + ly = ly.min(y); + hy = hy.max(y); + } + let sx = (hx - lx).max(1e-3); + let sy = (hy - ly).max(1e-3); + let pts: Vec = raw.iter().map(|&(x, y)| p((x - lx) / sx, (y - ly) / sy)).collect(); + self.line(&pts, c); + } + + /// Isometric "layer" diamond. + pub(super) fn layer(&self, cx: f32, cy: f32, hw: f32, hh: f32, fill: C, stroke: C) { + self.rshape(&[(cx, cy - hh), (cx + hw, cy), (cx, cy + hh), (cx - hw, cy)], 0.8, fill, stroke); + } + + /// Open book. + pub(super) fn book(&self, fill: C, col: C) { + let left = { + let mut p = quad_pts((12.0, 7.0), (7.5, 4.0), (2.0, 5.0), 8); + p.extend(quad_pts((2.0, 18.5), (7.5, 17.5), (12.0, 20.5), 8)); + p + }; + let right = { + let mut p = quad_pts((12.0, 7.0), (16.5, 4.0), (22.0, 5.0), 8); + p.extend(quad_pts((22.0, 18.5), (16.5, 17.5), (12.0, 20.5), 8)); + p + }; + self.shape(&left, fill, col); + self.shape(&right, fill, col); + } + + pub(super) fn monitor(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.rect(x0, y0, x1, y1, 1.5, N, K); + let cx = (x0 + x1) * 0.5; + self.seg((cx, y1), (cx, y1 + 3.2), K); + self.seg((cx - 4.0, y1 + 3.6), (cx + 4.0, y1 + 3.6), K); + } + + pub(super) fn clock_badge(&self, c: P2, r: f32, fill: C) { + let hc = if self.dis { + self.circle(c, r, N, fill); + K + } else { + self.circle(c, r, fill, N); + W + }; + self.line_w(&[(c.0, c.1 - r * 0.6), c, (c.0 + r * 0.5, c.1 + r * 0.2)], hc, 0.9); + } + + pub(super) fn gear_badge(&self, c: P2, r: f32, fill: C) { + self.gear(c, r, r * 0.74, 8, fill, N); + if self.dis { + self.gear(c, r, r * 0.74, 8, N, fill); + } + self.dot(c, r * 0.32, if self.dis { N } else { W }); + if self.dis { + self.circle(c, r * 0.32, N, fill); + } + } + + pub(super) fn lock(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + let cx = (x0 + x1) * 0.5; + let r = (x1 - x0) * 0.3; + let mut sh = vec![(cx - r, y0)]; + sh.extend(arc_pts((cx, y0 - r * 0.7), r, r, 180.0, 360.0)); + sh.push((cx + r, y0)); + self.line(&sh, K); + self.rect(x0, y0, x1, y1, 1.2, Y, K); + let ky = (y0 + y1) * 0.5; + self.dot((cx, ky - 0.4), 1.1, D); + self.seg_w((cx, ky), (cx, ky + 2.0), D, 0.9); + } + + pub(super) fn cursor(&self, x: f32, y: f32, s: f32) { + let p = |u: f32, v: f32| (x + u * s, y + v * s); + let pts = [p(0.0, 0.0), p(0.0, 15.0), p(3.8, 11.6), p(6.4, 17.0), p(8.6, 16.0), p(6.1, 10.8), p(11.0, 10.8)]; + self.shape(&pts, K, K); + } +} From 28371e27d75792a07cd818f41643daa5ab0f0efd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:47:08 +0000 Subject: [PATCH 47/90] refactor(ui-egui): split backstage.rs into backstage/{files,info,print}.rs --- crates/ui-egui/src/backstage.rs | 933 +------------------------- crates/ui-egui/src/backstage/files.rs | 316 +++++++++ crates/ui-egui/src/backstage/info.rs | 242 +++++++ crates/ui-egui/src/backstage/print.rs | 370 ++++++++++ 4 files changed, 939 insertions(+), 922 deletions(-) create mode 100644 crates/ui-egui/src/backstage/files.rs create mode 100644 crates/ui-egui/src/backstage/info.rs create mode 100644 crates/ui-egui/src/backstage/print.rs diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 0c3b11d9b..8d91d121c 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -18,6 +18,17 @@ use crate::SlideApp; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +mod files; +mod info; +mod print; + +pub use files::split_path; +use files::{home, new_page, open_page}; +use info::{PROPERTIES, account, commit_props, info, save_as}; +pub use info::{SAVE_TYPES, format_bytes}; +use print::{export, print}; +pub use print::{print_page_size, print_params, print_slides}; + /// Width of the side menu. pub const NAV_W: f32 = 160.0; /// Height of a side-menu entry (the back arrow's row is a little taller). @@ -215,28 +226,6 @@ impl Backstage { } } -/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). -pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ - ("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"), - ("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"), - ("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"), - ("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"), - ("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"), - ("png", "PNG Picture (*.png)", "The current slide as a picture"), - ("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"), -]; - -/// Info page properties: (`file.properties` key, label, hint). -const PROPERTIES: [(&str, &str, &str); 7] = [ - ("title", "Title", "Add a title"), - ("subject", "Subject", "Specify the subject"), - ("keywords", "Tags", "Add a tag"), - ("category", "Categories", "Add a category"), - ("company", "Company", "Specify the company"), - ("comments", "Comments", "Add comments"), - ("author", "Author", "Add an author"), -]; - /// What a click asked for, applied after drawing. #[derive(Default)] struct Outcome { @@ -493,763 +482,6 @@ fn search(ui: &mut Ui, query: &mut String, hint: &str, width: f32) -> bool { ui.add(egui::TextEdit::singleline(query).hint_text(hint).desired_width(width)).changed() } -/// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. -fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let thumb_w = if big { 200.0 } else { 160.0 }; - let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); - let p = ui.painter(); - if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(4), t.hover); - } - let thumb = Rect::from_min_size(rect.min + vec2(8.0, 10.0), vec2(thumb_w, thumb_w * 9.0 / 16.0)); - match theme { - Some(th) => crate::ribbon::paint_theme_tile(p, thumb, th), - None => { - p.rect_filled(thumb, CornerRadius::same(2), Color32::WHITE); - p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); - } - } - if resp.hovered() { - p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(2.0, t.accent), StrokeKind::Outside); - } - let galley = p.layout(label.to_string(), theme::font(12.0), t.text, rect.width() - 8.0); - p.galley(pos2(rect.center().x - galley.size().x / 2.0, thumb.bottom() + 8.0), galley, t.text); - resp -} - -/// The built-in themes and the sample deck, filtered by `query` (after a blank presentation when -/// `blank`); at most `limit` theme tiles. False when nothing matched. -fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: bool) -> bool { - let q = query.trim().to_lowercase(); - let themes: Vec = - deckcraft_model::theme::builtin_themes().into_iter().filter(|th| q.is_empty() || th.name.to_lowercase().contains(&q)).collect(); - let sample = q.is_empty() || "sample deck northern lights".contains(&q); - if themes.is_empty() && !sample { - return false; - } - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - if blank && tile(ui, "Blank Presentation", None, false).clicked() { - let _ = app.run("file.new", json!({})); - } - let mut shown = 0; - for th in themes { - if shown >= limit { - break; - } - if tile(ui, &th.name, Some(&th), false).clicked() { - let _ = app.run("file.new", json!({"theme": th.name})); - } - shown += 1; - } - if sample && shown < limit { - let th = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == "DeckCraft"); - if tile(ui, "Sample deck: Northern Lights", th.as_ref(), false).clicked() - && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) - { - app.set_status(e.to_string()); - } - } - }); - true -} - -/// Starter decks whose name, summary, theme or a slide title matches `query`, at most `limit` tiles. -fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) -> bool { - let list = deckcraft_engine::templates::matching(query); - if list.is_empty() { - return false; - } - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - for starter in list.into_iter().take(limit) { - let theme = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == starter.theme); - if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(starter.summary).clicked() { - let _ = app.run("file.new", json!({"template": starter.name})); - } - } - }); - true -} - -/// File name and folder of a recent path, split as text (nothing on disk is read). -pub fn split_path(path: &str) -> (String, String) { - let p = std::path::Path::new(path); - let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string(); - let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string(); - (name, folder) -} - -/// Recent presentations whose name matches `query`, newest first, at most `limit`. Opening one is -/// the user's explicit choice. -fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { - let t = Tokens::get(ui.ctx()); - if app.ui.recent.is_empty() { - dim(ui, "Presentations you open show up here. Use Open to browse for one."); - return; - } - let q = query.trim().to_lowercase(); - let list: Vec = - app.ui.recent.iter().filter(|p| q.is_empty() || split_path(p).0.to_lowercase().contains(&q)).take(limit).cloned().collect(); - if list.is_empty() { - dim(ui, &format!("No recent presentations match “{}”.", query.trim())); - return; - } - let width = ui.available_width().min(CONTENT_W); - for path in list { - let missing = !app.recent_exists(&path); - let (name, folder) = split_path(&path); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); - let p = ui.painter(); - if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(3), t.hover); - } - let ink = if missing { t.text_dim } else { t.text }; - // The row is as wide as the page's content, which a narrow window clips: keep the mark - // and the remove button inside the visible part of the row. - let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); - { - let p = ui.painter(); - icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); - p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); - p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); - // A missing file says so before it is clicked (#172). - if missing { - p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); - } - } - let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; - // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon - // (a text glyph would need a font that has it). - let x = ui.allocate_rect( - Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), - Sense::click(), - ); - x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent")); - if x.hovered() { - ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); - } - icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); - if x.on_hover_text("Remove from Recent").clicked() { - app.forget_recent(&path); - } - if resp.clicked() { - app.open_path_in_background(&path); - } - } -} - -/// A large square command button (Print, Create PDF, Check for Issues…). -fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); - let p = ui.painter(); - p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card }); - p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside); - icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 26.0), vec2(26.0, 26.0)), icon, t.text, !enabled); - let ink = if enabled { t.text } else { t.text_faint }; - let galley = p.layout(label.to_string(), theme::font(12.0), ink, rect.width() - 8.0); - p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 46.0), galley, ink); - resp -} - -/// A row of a page's own list on the left (Open's places, Export's tasks). -fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); - let p = ui.painter(); - if selected { - p.rect_filled(rect, CornerRadius::same(2), t.selected); - p.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, t.border), StrokeKind::Inside); - } else if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(2), t.hover); - } - icons::paint(p, Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text, false); - p.text(pos2(rect.left() + 46.0, rect.center().y), Align2::LEFT_CENTER, label, theme::font(13.5), t.text); - resp -} - -/// A square button with its title and description beside it (Info, Export). -fn task(ui: &mut Ui, icon: Icon, button: &str, title: &str, detail: &str, enabled: bool) -> bool { - let t = Tokens::get(ui.ctx()); - let mut clicked = false; - ui.horizontal_top(|ui| { - clicked = square_button(ui, icon, button, enabled).clicked(); - ui.add_space(12.0); - ui.vertical(|ui| { - ui.add_space(4.0); - ui.label(RichText::new(title).font(theme::font(15.0)).color(t.text)); - dim(ui, detail); - }); - }); - ui.add_space(14.0); - clicked -} - -// ---------------------------------------------------------------- pages - -/// Home: DeckCraft's mark, new presentations (the first themes, with a link to New) and recent -/// presentations (with a link to Open). The same page is the start screen. -fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); - crate::dialogs::paint_logo(ui.painter(), r); - ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); - }); - ui.add_space(18.0); - ui.horizontal(|ui| { - ui.label(RichText::new("New").font(theme::bold(16.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if arrow_link(ui, "More themes") { - out.page = Some(Page::New); - } - }); - }); - ui.add_space(6.0); - new_tiles(app, ui, "", 5, true); - ui.add_space(22.0); - ui.horizontal(|ui| { - ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if arrow_link(ui, "More templates") { - out.page = Some(Page::New); - } - }); - }); - ui.add_space(6.0); - template_tiles(app, ui, "", 3); - ui.add_space(22.0); - ui.horizontal(|ui| { - ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - search(ui, &mut bs.query, "Search for a file", 280.0); - }); - }); - ui.add_space(6.0); - recent_list(app, ui, &bs.query.clone(), 8); - if app.ui.recent.len() > 8 { - ui.add_space(6.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - if arrow_link(ui, "More presentations") { - out.page = Some(Page::Open); - } - }); - } - ui.add_space(30.0); - ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); -} - -/// New: a blank presentation, then every theme and starter template, with a search box. -fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - if tile(ui, "Blank Presentation", None, true).clicked() { - let _ = app.run("file.new", json!({})); - } - ui.add_space(8.0); - let w = ui.available_width(); - let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.separator)); - ui.add_space(14.0); - search(ui, &mut bs.query, "Search for themes and templates", 380.0); - ui.add_space(14.0); - let q = bs.query.clone(); - let themes = new_tiles(app, ui, &q, usize::MAX, false); - let templates = if q.trim().is_empty() || !deckcraft_engine::templates::matching(&q).is_empty() { - ui.add_space(18.0); - heading(ui, "Templates"); - template_tiles(app, ui, &q, usize::MAX) - } else { - false - }; - if !themes && !templates { - dim(ui, &format!("No themes or templates match “{}”.", q.trim())); - } -} - -/// Open: Recent and Browse on the left; the recent presentations (with a search box) on the right. -fn open_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(230.0); - place_row(ui, Icon::Recent, "Recent", true, 220.0); - if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text("Open a presentation from a folder (Ctrl+O)").clicked() { - let _ = app.run("app.openDialog", json!({})); - } - if app.session.recovery_dir.is_some() { - ui.add_space(24.0); - if ui.button("Recover Unsaved Presentations").clicked() { - match app.run("file.recovery.open", json!({})) { - Ok(v) if v.get("opened").and_then(Value::as_array).is_some_and(|a| a.is_empty()) => { - app.set_status("There are no unsaved presentations to recover"); - } - _ => {} - } - } - } - }); - let h = ui.available_height().max(320.0); - let (r, _) = ui.allocate_exact_size(vec2(1.0, h), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - ui.add_space(24.0); - ui.vertical(|ui| { - search(ui, &mut bs.query, "Search for a file", 380.0); - ui.add_space(12.0); - heading(ui, "Presentations"); - let q = bs.query.clone(); - recent_list(app, ui, &q, usize::MAX); - }); - }); -} - -/// Info: the presentation's name and location, Inspect and Manage on the left, its properties -/// (editable, through `file.properties`) on the right. -fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - let Ok(v) = app.session.execute("file.info", &json!({})) else { - dim(ui, "No presentation is open."); - return; - }; - let path = v.get("path").and_then(Value::as_str).map(str::to_string); - let title = v.get("title").and_then(Value::as_str).unwrap_or("").to_string(); - let dirty = v.get("dirty").and_then(Value::as_bool).unwrap_or(false); - ui.label(RichText::new(&title).font(theme::font(24.0)).color(t.accent)); - match &path { - Some(p) => dim(ui, p), - None => dim(ui, "Not saved yet"), - } - if let Some(p) = &path { - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Copy path").clicked() { - ui.ctx().copy_text(p.clone()); - app.set_status("Copied the path"); - } - #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str) - && ui.button("Open file location").clicked() - { - app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/'))); - } - }); - } - ui.add_space(20.0); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(440.0); - if task( - ui, - Icon::Accessibility, - "Check for Issues", - "Inspect Presentation", - "Before you share it, check the presentation for things people with disabilities might find hard to read.", - true, - ) { - let _ = app.run("app.dialog", json!({"id": "accessibility"})); - } - let (detail, can_revert) = match (dirty, path.is_some()) { - (false, _) => ("There are no unsaved changes.", false), - (true, true) => ("This presentation has unsaved changes. Revert goes back to the saved file.", true), - (true, false) => ("This presentation has never been saved.", false), - }; - if task(ui, Icon::ResetSlide, "Revert", "Manage Presentation", detail, can_revert) { - let _ = app.run("file.revert", json!({})); - } - }); - ui.add_space(30.0); - ui.vertical(|ui| { - heading(ui, "Properties"); - let props = v.get("properties").cloned().unwrap_or_default(); - let size = v.get("bytes").and_then(Value::as_u64).map(format_bytes); - egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 7.0)).show(ui, |ui| { - let row = |ui: &mut Ui, k: &str, val: String| { - ui.label(RichText::new(k).color(t.text_dim)); - ui.label(val); - ui.end_row(); - }; - if let Some(s) = size { - row(ui, "Size", s); - } - row(ui, "Slides", v.get("slides").and_then(Value::as_u64).unwrap_or(0).to_string()); - row(ui, "Hidden slides", v.get("hiddenSlides").and_then(Value::as_u64).unwrap_or(0).to_string()); - for (i, (key, label, hint)) in PROPERTIES.iter().enumerate() { - let saved = props.get(*key).and_then(Value::as_str).unwrap_or("").to_string(); - let Some(draft) = bs.props.get_mut(i) else { continue }; - let l = ui.label(RichText::new(*label).color(t.text_dim)); - let id = ui.make_persistent_id(("backstage_prop", *key)); - let resp = ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(*hint).desired_width(300.0)).labelled_by(l.id); - if resp.lost_focus() && *draft != saved { - // Commit first: a click on another field takes the focus before this one is drawn. - let mut p = serde_json::Map::new(); - p.insert((*key).to_string(), json!(draft.clone())); - let _ = app.run("file.properties", Value::Object(p)); - } else if !resp.has_focus() && *draft != saved { - // Show the presentation's value unless the field is being edited (undo...). - draft.clone_from(&saved); - ui.ctx().request_repaint(); - } - ui.end_row(); - } - row(ui, "Location", path.clone().unwrap_or_else(|| "Not saved yet".into())); - }); - }); - }); -} - -/// Commits what the Info page's property fields hold and the presentation does not: a side-menu -/// action such as Save runs before the field that is being left is drawn again (#422). -fn commit_props(app: &mut SlideApp, bs: &Backstage) { - if bs.page != Page::Info { - return; - } - let Ok(v) = app.session.execute("file.info", &json!({})) else { return }; - let mut p = serde_json::Map::new(); - for ((key, _, _), draft) in PROPERTIES.iter().zip(&bs.props) { - if v["properties"].get(*key).and_then(Value::as_str).unwrap_or("") != *draft { - p.insert((*key).to_string(), json!(draft)); - } - } - if !p.is_empty() { - let _ = app.run("file.properties", Value::Object(p)); - } -} - -/// `1234567` → `1.2 MB`. -pub fn format_bytes(b: u64) -> String { - match b { - 0..1024 => format!("{b} bytes"), - 1024..1_048_576 => format!("{:.1} KB", b as f64 / 1024.0), - _ => format!("{:.1} MB", b as f64 / 1_048_576.0), - } -} - -/// Save As: the file type, then the system Save dialog (desktop) or a download (web). A -/// presentation saved as .pptx or .deckcraft takes the new file; the other types are copies. -fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { - let t = Tokens::get(ui.ctx()); - let (title, path) = app.session.active().map(|d| (d.title(), d.path.clone())).unwrap_or_default(); - ui.label(RichText::new(&title).font(theme::font(15.0)).color(t.text)); - dim(ui, path.as_deref().unwrap_or("Not saved yet")); - ui.add_space(14.0); - let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("backstage_save_type") - .width(340.0) - .selected_text(current) - .show_ui(ui, |ui| { - for (id, label, _) in SAVE_TYPES { - if ui.selectable_label(bs.file_type == id, label).clicked() { - bs.file_type = id; - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); - let web = app.services.pick_save.is_none(); - let label = if web { "Download" } else { "Save" }; - let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); - if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() { - let fmt = bs.file_type; - let done = if matches!(fmt, "pptx" | "deckcraft") { - let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); - app.save_as_dialog(fmt); - web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before - } else { - app.export_as(fmt, json!({})) - }; - if done { - out.close = true; - } - } - }); - ui.add_space(8.0); - if let Some((_, _, detail)) = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type) { - dim(ui, detail); - } -} - -/// The slides the Print page prints, in order (0-based). -pub fn print_slides(doc: &deckcraft_model::Presentation, bs: &Backstage, current: usize) -> Result, String> { - let n = doc.slides.len(); - let mut v: Vec = match bs.print_scope { - PrintScope::All => (0..n).collect(), - PrintScope::Current => vec![current.min(n.saturating_sub(1))], - PrintScope::Custom => deckcraft_engine::cmd::file::parse_slide_range(&bs.print_range, n).map_err(|e| e.to_string())?, - }; - if !bs.print_hidden && bs.print_scope != PrintScope::Current { - v.retain(|i| doc.slides.get(*i).is_some_and(|s| !s.hidden)); - } - Ok(v) -} - -/// Handout slides per page for a `print_layout` id (`h3` → 3). -fn per_page(layout: &str) -> usize { - layout.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| matches!(n, 1 | 2 | 3 | 4 | 6 | 9)).unwrap_or(1) -} - -/// The `file.export` / `app.print` params for the Print page's settings. -pub fn print_params(bs: &Backstage, slides: &[usize]) -> Value { - let (layout, per) = match bs.print_layout { - "notes" => ("notes", 0), - l if l.starts_with('h') => ("handouts", per_page(l)), - _ => ("slides", 0), - }; - // The list is already filtered for hidden slides, so the PDF takes it as it is. - json!({ - "layout": layout, - "perPage": per, - "slides": slides, - "includeHidden": true, - "frame": bs.print_frame, - "paper": bs.print_paper, - "orientation": if bs.print_landscape { "landscape" } else { "portrait" }, - }) -} - -/// The printed page size in points: the slide size for full-page slides, else the chosen paper. -pub fn print_page_size(bs: &Backstage, doc: &deckcraft_model::Presentation) -> (f32, f32) { - if bs.print_layout == "slides" { - return (doc.slide_size.width as f32, doc.slide_size.height as f32); - } - let (w, h) = deckcraft_engine::cmd::file::paper_size(bs.print_paper, bs.print_landscape).unwrap_or((612.0, 792.0)); - (w as f32, h as f32) -} - -const LAYOUTS: [(&str, &str); 8] = [ - ("slides", "Full Page Slides"), - ("notes", "Notes Pages"), - ("h1", "Handouts (1 slide per page)"), - ("h2", "Handouts (2 slides per page)"), - ("h3", "Handouts (3 slides per page)"), - ("h4", "Handouts (4 slides per page)"), - ("h6", "Handouts (6 slides per page)"), - ("h9", "Handouts (9 slides per page)"), -]; - -/// Print: the Print button and settings on the left, a preview of each page on the right. -fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - let Some((doc, current)) = app.session.active().map(|d| (d.doc.clone(), d.selection.slide)) else { - dim(ui, "No presentation is open."); - return; - }; - let slides = print_slides(&doc, bs, current); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(250.0); - let ok = slides.as_ref().is_ok_and(|s| !s.is_empty()); - if square_button(ui, Icon::Print, "Print", ok).clicked() - && let Ok(s) = &slides - { - app.print_pdf(print_params(bs, s)); - } - ui.add_space(6.0); - dim( - ui, - if app.services.pick_save.is_some() { - "DeckCraft prints through a PDF: it opens in your PDF viewer, where you choose the printer." - } else { - "DeckCraft prints through a PDF: it downloads, ready to print from your browser or PDF viewer." - }, - ); - ui.add_space(14.0); - heading(ui, "Settings"); - let scope = match bs.print_scope { - PrintScope::All => "Print All Slides", - PrintScope::Current => "Print Current Slide", - PrintScope::Custom => "Custom Range", - }; - let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { - for (s, label) in - [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] - { - if ui.selectable_label(bs.print_scope == s, label).clicked() { - bs.print_scope = s; - bs.preview_page = 0; - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); - ui.add_space(4.0); - ui.horizontal(|ui| { - let l = ui.label("Slides:"); - let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text("e.g. 1-3, 5").desired_width(176.0)).labelled_by(l.id); - if r.changed() { - // Typing a range picks Custom Range, as in PowerPoint. - bs.print_scope = if bs.print_range.trim().is_empty() { PrintScope::All } else { PrintScope::Custom }; - bs.preview_page = 0; - } - }); - ui.add_space(4.0); - let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); - let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { - for (id, label) in LAYOUTS { - if ui.selectable_label(bs.print_layout == id, label).clicked() { - bs.print_layout = id; - bs.preview_page = 0; - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout")); - ui.add_space(4.0); - // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are - // for notes pages and handouts. - ui.add_enabled_ui(bs.print_layout != "slides", |ui| { - let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); - egui::ComboBox::from_id_salt("backstage_print_paper") - .width(240.0) - .selected_text(paper) - .show_ui(ui, |ui| { - for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { - if ui.selectable_label(bs.print_paper == id, label).clicked() { - bs.print_paper = id; - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); - ui.add_space(4.0); - let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; - egui::ComboBox::from_id_salt("backstage_print_orientation") - .width(240.0) - .selected_text(orient) - .show_ui(ui, |ui| { - for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { - if ui.selectable_label(bs.print_landscape == land, label).clicked() { - bs.print_landscape = land; - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); - }); - ui.add_space(4.0); - if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { - bs.preview_page = 0; - } - ui.checkbox(&mut bs.print_frame, "Frame Slides"); - ui.add_space(6.0); - if ui.link("Edit Header & Footer").clicked() { - let _ = app.run("app.dialog", json!({"id": "headerFooter"})); - } - }); - ui.add_space(20.0); - let (r, _) = ui.allocate_exact_size(vec2(1.0, 560.0), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - ui.add_space(20.0); - ui.vertical(|ui| match &slides { - Err(e) => dim(ui, e), - Ok(s) if s.is_empty() => dim(ui, "Nothing to print: every slide in the range is hidden."), - Ok(s) => preview(app, ui, bs, &doc, s), - }); - }); -} - -/// One printed page in miniature, with a pager under it. -fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_model::Presentation, slides: &[usize]) { - let t = Tokens::get(ui.ctx()); - let per = match bs.print_layout { - "slides" | "notes" => 1, - l => per_page(l), - }; - let pages: Vec<&[usize]> = slides.chunks(per.max(1)).collect(); - let last = pages.len().saturating_sub(1); - bs.preview_page = bs.preview_page.min(last); - let Some(on_page) = pages.get(bs.preview_page).copied() else { return }; - // Full-page slides print on slide-shaped pages; notes and handouts on the chosen paper. - let (pw, ph) = print_page_size(bs, doc); - let avail_w = ui.available_width().clamp(200.0, 760.0); - let scale = (avail_w / pw.max(1.0)).min(520.0 / ph.max(1.0)); - let (area, _) = ui.allocate_exact_size(vec2(avail_w, ph * scale + 24.0), Sense::hover()); - ui.painter().rect_filled(area, CornerRadius::same(2), t.canvas); - let page = Rect::from_center_size(area.center(), vec2(pw * scale, ph * scale)); - let p = ui.painter(); - p.rect_filled(page.translate(vec2(2.0, 3.0)), 0.0, t.shadow); - p.rect_filled(page, 0.0, Color32::WHITE); - let aspect = (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let mut budget = 4u32; - let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| { - let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32; - match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) { - Some(tex) => { - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - None => ui.ctx().request_repaint(), - } - if bs.print_frame || bs.print_layout != "slides" { - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); - } - }; - match bs.print_layout { - "slides" => { - if let Some(i) = on_page.first() { - draw_slide(ui, *i, page); - } - } - "notes" => { - if let Some(i) = on_page.first() { - let w = page.width() * 0.75; - let r = Rect::from_min_size(pos2(page.center().x - w / 2.0, page.top() + page.height() * 0.08), vec2(w, w * aspect)); - draw_slide(ui, *i, r); - let notes = doc.slides.get(*i).map(|s| s.notes_text()).unwrap_or_default(); - let font = theme::font((11.0 * scale).clamp(5.0, 13.0)); - let text_rect = Rect::from_min_max( - pos2(page.left() + page.width() * 0.12, r.bottom() + 20.0 * scale), - page.max - vec2(page.width() * 0.12, 30.0 * scale), - ); - let galley = ui.painter().layout(notes, font, Color32::from_gray(30), text_rect.width()); - ui.painter().with_clip_rect(text_rect).galley(text_rect.min, galley, Color32::from_gray(30)); - } - } - l => { - let n = per_page(l); - let (cols, rows) = match n { - 1 => (1, 1), - 2 => (1, 2), - 3 => (1, 3), - 4 => (2, 2), - 6 => (2, 3), - _ => (3, 3), - }; - let inner = page.shrink2(vec2(page.width() * 0.08, page.height() * 0.07)); - // Three a page leaves lines for notes beside each slide, as PowerPoint's handouts do. - let slide_area = if n == 3 { Rect::from_min_size(inner.min, vec2(inner.width() * 0.5, inner.height())) } else { inner }; - let cell = vec2(slide_area.width() / cols as f32, slide_area.height() / rows as f32); - for (k, i) in on_page.iter().enumerate() { - let (c, r) = ((k % cols) as f32, (k / cols) as f32); - let cell_rect = Rect::from_min_size(slide_area.min + vec2(c * cell.x, r * cell.y), cell).shrink(cell.x.min(cell.y) * 0.08); - let w = cell_rect.width().min(cell_rect.height() / aspect.max(0.01)); - let sr = Rect::from_center_size(cell_rect.center(), vec2(w, w * aspect)); - draw_slide(ui, *i, sr); - if n == 3 { - for line in 1..=5 { - let y = sr.top() + sr.height() * line as f32 / 6.0; - ui.painter() - .line_segment([pos2(inner.center().x + 10.0, y), pos2(inner.right(), y)], Stroke::new(0.8, Color32::from_gray(150))); - } - } - } - } - } - ui.add_space(6.0); - ui.horizontal(|ui| { - let prev = icon_button(ui, Icon::ChevronLeft, "Previous Page", bs.preview_page > 0); - if prev { - bs.preview_page = bs.preview_page.saturating_sub(1); - } - ui.label(format!("{} of {}", bs.preview_page + 1, pages.len())); - if icon_button(ui, Icon::ChevronRight, "Next Page", bs.preview_page < last) { - bs.preview_page = (bs.preview_page + 1).min(last); - } - }); -} - /// Draw a labelled icon button and return whether it was clicked while enabled. fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -1262,97 +494,6 @@ fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { resp.on_hover_text(label).clicked() } -/// Export: tasks on the left (Create PDF Document, Create Handouts, Change File Type), the chosen -/// one's description and button on the right. PowerPoint's video, animated GIF and CD package -/// exports aren't offered. -fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { - let t = Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(250.0); - for (task, icon, label) in [ - (ExportTask::Pdf, Icon::File, "Create PDF Document"), - (ExportTask::Handouts, Icon::Notes, "Create Handouts"), - (ExportTask::FileType, Icon::Export, "Change File Type"), - ] { - if place_row(ui, icon, label, bs.export == task, 244.0).clicked() { - bs.export = task; - } - } - }); - let (r, _) = ui.allocate_exact_size(vec2(1.0, 420.0), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - ui.add_space(24.0); - ui.vertical(|ui| { - ui.set_max_width(560.0); - match bs.export { - ExportTask::Pdf => { - ui.label(RichText::new("Create a PDF Document").font(theme::font(16.0)).color(t.text)); - bullets( - ui, - &["Keeps the layout, fonts and pictures", "Opens on any device with a PDF viewer", "The presentation keeps its own file"], - ); - ui.checkbox(&mut bs.pdf_hidden, "Include hidden slides"); - ui.add_space(10.0); - if square_button(ui, Icon::File, "Create PDF", true).clicked() { - app.export_as("pdf", json!({"includeHidden": bs.pdf_hidden})); - } - } - ExportTask::Handouts => { - ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); - bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); - let combo = egui::ComboBox::from_id_salt("backstage_handouts") - .width(240.0) - .selected_text(format!("{} slides per page", bs.handouts_per_page)) - .show_ui(ui, |ui| { - for n in [1u8, 2, 3, 4, 6, 9] { - if ui.selectable_label(bs.handouts_per_page == n, format!("{n} slides per page")).clicked() { - bs.handouts_per_page = n; - } - } - }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page")); - ui.add_space(10.0); - if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() { - app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page})); - } - } - ExportTask::FileType => { - ui.label(RichText::new("Change File Type").font(theme::font(16.0)).color(t.text)); - ui.add_space(8.0); - for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { - let selected = bs.file_type == *id; - let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label)); - let p = ui.painter(); - if selected { - p.rect_filled(rect, CornerRadius::same(2), t.selected); - } else if resp.hovered() { - p.rect_filled(rect, CornerRadius::same(2), t.hover); - } - p.text(pos2(rect.left() + 12.0, rect.center().y - 8.0), Align2::LEFT_CENTER, *label, theme::font(13.0), t.text); - p.text(pos2(rect.left() + 12.0, rect.center().y + 9.0), Align2::LEFT_CENTER, *detail, theme::font(11.5), t.text_dim); - if resp.clicked() { - bs.file_type = id; - } - } - ui.add_space(12.0); - let png = bs.file_type == "png"; - if png { - ui.checkbox(&mut bs.png_transparent, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); - ui.add_space(8.0); - } - if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { - let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) }; - app.export_as(bs.file_type, extra); - } - } - } - }); - }); -} - fn bullets(ui: &mut Ui, items: &[&str]) { ui.add_space(4.0); for b in items { @@ -1364,57 +505,5 @@ fn bullets(ui: &mut Ui, items: &[&str]) { ui.add_space(10.0); } -/// Account: the interface's appearance and what DeckCraft is. DeckCraft has no accounts, so -/// there is nothing to sign in to. -fn account(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(340.0); - heading(ui, "Appearance"); - let dark = app.ui.brightness == theme::Brightness::Dark; - egui::ComboBox::from_id_salt("backstage_appearance") - .width(260.0) - .selected_text(if dark { "Dark" } else { "Light" }) - .show_ui(ui, |ui| { - for (on, label) in [(false, "Light"), (true, "Dark")] { - if ui.selectable_label(dark == on, label).clicked() { - let _ = app.run("view.dark", json!({"on": on})); - } - } - }) - .response - .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); - ui.add_space(16.0); - heading(ui, "User Information"); - dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device."); - }); - ui.add_space(40.0); - ui.vertical(|ui| { - heading(ui, "Product Information"); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); - crate::dialogs::paint_logo(ui.painter(), r); - ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); - }); - dim(ui, &crate::dialogs::version_line()); - dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft."); - ui.add_space(14.0); - if task( - ui, - Icon::Help, - "About DeckCraft", - "About DeckCraft", - "The version, the people and models who built it, and where to find us.", - true, - ) { - let _ = app.run("app.about", json!({})); - } - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); - ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); - }); - }); -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/backstage/files.rs b/crates/ui-egui/src/backstage/files.rs new file mode 100644 index 000000000..58bdf94b9 --- /dev/null +++ b/crates/ui-egui/src/backstage/files.rs @@ -0,0 +1,316 @@ +use super::*; + +/// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. +fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let thumb_w = if big { 200.0 } else { 160.0 }; + let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(4), t.hover); + } + let thumb = Rect::from_min_size(rect.min + vec2(8.0, 10.0), vec2(thumb_w, thumb_w * 9.0 / 16.0)); + match theme { + Some(th) => crate::ribbon::paint_theme_tile(p, thumb, th), + None => { + p.rect_filled(thumb, CornerRadius::same(2), Color32::WHITE); + p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + } + } + if resp.hovered() { + p.rect_stroke(thumb, CornerRadius::same(2), Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = p.layout(label.to_string(), theme::font(12.0), t.text, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, thumb.bottom() + 8.0), galley, t.text); + resp +} + +/// The built-in themes and the sample deck, filtered by `query` (after a blank presentation when +/// `blank`); at most `limit` theme tiles. False when nothing matched. +fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: bool) -> bool { + let q = query.trim().to_lowercase(); + let themes: Vec = + deckcraft_model::theme::builtin_themes().into_iter().filter(|th| q.is_empty() || th.name.to_lowercase().contains(&q)).collect(); + let sample = q.is_empty() || "sample deck northern lights".contains(&q); + if themes.is_empty() && !sample { + return false; + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + if blank && tile(ui, "Blank Presentation", None, false).clicked() { + let _ = app.run("file.new", json!({})); + } + let mut shown = 0; + for th in themes { + if shown >= limit { + break; + } + if tile(ui, &th.name, Some(&th), false).clicked() { + let _ = app.run("file.new", json!({"theme": th.name})); + } + shown += 1; + } + if sample && shown < limit { + let th = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == "DeckCraft"); + if tile(ui, "Sample deck: Northern Lights", th.as_ref(), false).clicked() + && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) + { + app.set_status(e.to_string()); + } + } + }); + true +} + +/// Starter decks whose name, summary, theme or a slide title matches `query`, at most `limit` tiles. +fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) -> bool { + let list = deckcraft_engine::templates::matching(query); + if list.is_empty() { + return false; + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + for starter in list.into_iter().take(limit) { + let theme = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == starter.theme); + if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(starter.summary).clicked() { + let _ = app.run("file.new", json!({"template": starter.name})); + } + } + }); + true +} + +/// File name and folder of a recent path, split as text (nothing on disk is read). +pub fn split_path(path: &str) -> (String, String) { + let p = std::path::Path::new(path); + let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string(); + let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string(); + (name, folder) +} + +/// Recent presentations whose name matches `query`, newest first, at most `limit`. Opening one is +/// the user's explicit choice. +fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { + let t = Tokens::get(ui.ctx()); + if app.ui.recent.is_empty() { + dim(ui, "Presentations you open show up here. Use Open to browse for one."); + return; + } + let q = query.trim().to_lowercase(); + let list: Vec = + app.ui.recent.iter().filter(|p| q.is_empty() || split_path(p).0.to_lowercase().contains(&q)).take(limit).cloned().collect(); + if list.is_empty() { + dim(ui, &format!("No recent presentations match “{}”.", query.trim())); + return; + } + let width = ui.available_width().min(CONTENT_W); + for path in list { + let missing = !app.recent_exists(&path); + let (name, folder) = split_path(&path); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(3), t.hover); + } + let ink = if missing { t.text_dim } else { t.text }; + // The row is as wide as the page's content, which a narrow window clips: keep the mark + // and the remove button inside the visible part of the row. + let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); + { + let p = ui.painter(); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); + p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); + p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); + // A missing file says so before it is clicked (#172). + if missing { + p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); + } + } + let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; + // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon + // (a text glyph would need a font that has it). + let x = ui.allocate_rect( + Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), + Sense::click(), + ); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent")); + if x.hovered() { + ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); + if x.on_hover_text("Remove from Recent").clicked() { + app.forget_recent(&path); + } + if resp.clicked() { + app.open_path_in_background(&path); + } + } +} + +/// A large square command button (Print, Create PDF, Check for Issues…). +pub(super) fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); + let p = ui.painter(); + p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card }); + p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside); + icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 26.0), vec2(26.0, 26.0)), icon, t.text, !enabled); + let ink = if enabled { t.text } else { t.text_faint }; + let galley = p.layout(label.to_string(), theme::font(12.0), ink, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 46.0), galley, ink); + resp +} + +/// A row of a page's own list on the left (Open's places, Export's tasks). +pub(super) fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, CornerRadius::same(2), t.selected); + p.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, t.border), StrokeKind::Inside); + } else if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(2), t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text, false); + p.text(pos2(rect.left() + 46.0, rect.center().y), Align2::LEFT_CENTER, label, theme::font(13.5), t.text); + resp +} + +/// A square button with its title and description beside it (Info, Export). +pub(super) fn task(ui: &mut Ui, icon: Icon, button: &str, title: &str, detail: &str, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let mut clicked = false; + ui.horizontal_top(|ui| { + clicked = square_button(ui, icon, button, enabled).clicked(); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(4.0); + ui.label(RichText::new(title).font(theme::font(15.0)).color(t.text)); + dim(ui, detail); + }); + }); + ui.add_space(14.0); + clicked +} + +// ---------------------------------------------------------------- pages + +/// Home: DeckCraft's mark, new presentations (the first themes, with a link to New) and recent +/// presentations (with a link to Open). The same page is the start screen. +pub(super) fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); + crate::dialogs::paint_logo(ui.painter(), r); + ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + ui.label(RichText::new("New").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if arrow_link(ui, "More themes") { + out.page = Some(Page::New); + } + }); + }); + ui.add_space(6.0); + new_tiles(app, ui, "", 5, true); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if arrow_link(ui, "More templates") { + out.page = Some(Page::New); + } + }); + }); + ui.add_space(6.0); + template_tiles(app, ui, "", 3); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + search(ui, &mut bs.query, "Search for a file", 280.0); + }); + }); + ui.add_space(6.0); + recent_list(app, ui, &bs.query.clone(), 8); + if app.ui.recent.len() > 8 { + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if arrow_link(ui, "More presentations") { + out.page = Some(Page::Open); + } + }); + } + ui.add_space(30.0); + ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); +} + +/// New: a blank presentation, then every theme and starter template, with a search box. +pub(super) fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + if tile(ui, "Blank Presentation", None, true).clicked() { + let _ = app.run("file.new", json!({})); + } + ui.add_space(8.0); + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.separator)); + ui.add_space(14.0); + search(ui, &mut bs.query, "Search for themes and templates", 380.0); + ui.add_space(14.0); + let q = bs.query.clone(); + let themes = new_tiles(app, ui, &q, usize::MAX, false); + let templates = if q.trim().is_empty() || !deckcraft_engine::templates::matching(&q).is_empty() { + ui.add_space(18.0); + heading(ui, "Templates"); + template_tiles(app, ui, &q, usize::MAX) + } else { + false + }; + if !themes && !templates { + dim(ui, &format!("No themes or templates match “{}”.", q.trim())); + } +} + +/// Open: Recent and Browse on the left; the recent presentations (with a search box) on the right. +pub(super) fn open_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(230.0); + place_row(ui, Icon::Recent, "Recent", true, 220.0); + if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text("Open a presentation from a folder (Ctrl+O)").clicked() { + let _ = app.run("app.openDialog", json!({})); + } + if app.session.recovery_dir.is_some() { + ui.add_space(24.0); + if ui.button("Recover Unsaved Presentations").clicked() { + match app.run("file.recovery.open", json!({})) { + Ok(v) if v.get("opened").and_then(Value::as_array).is_some_and(|a| a.is_empty()) => { + app.set_status("There are no unsaved presentations to recover"); + } + _ => {} + } + } + } + }); + let h = ui.available_height().max(320.0); + let (r, _) = ui.allocate_exact_size(vec2(1.0, h), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + ui.add_space(24.0); + ui.vertical(|ui| { + search(ui, &mut bs.query, "Search for a file", 380.0); + ui.add_space(12.0); + heading(ui, "Presentations"); + let q = bs.query.clone(); + recent_list(app, ui, &q, usize::MAX); + }); + }); +} diff --git a/crates/ui-egui/src/backstage/info.rs b/crates/ui-egui/src/backstage/info.rs new file mode 100644 index 000000000..e1e23027e --- /dev/null +++ b/crates/ui-egui/src/backstage/info.rs @@ -0,0 +1,242 @@ +use super::files::task; +use super::*; + +/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). +pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ + ("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"), + ("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"), + ("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"), + ("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"), + ("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"), + ("png", "PNG Picture (*.png)", "The current slide as a picture"), + ("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"), +]; + +/// Info page properties: (`file.properties` key, label, hint). +pub(super) const PROPERTIES: [(&str, &str, &str); 7] = [ + ("title", "Title", "Add a title"), + ("subject", "Subject", "Specify the subject"), + ("keywords", "Tags", "Add a tag"), + ("category", "Categories", "Add a category"), + ("company", "Company", "Specify the company"), + ("comments", "Comments", "Add comments"), + ("author", "Author", "Add an author"), +]; + +/// Info: the presentation's name and location, Inspect and Manage on the left, its properties +/// (editable, through `file.properties`) on the right. +pub(super) fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("file.info", &json!({})) else { + dim(ui, "No presentation is open."); + return; + }; + let path = v.get("path").and_then(Value::as_str).map(str::to_string); + let title = v.get("title").and_then(Value::as_str).unwrap_or("").to_string(); + let dirty = v.get("dirty").and_then(Value::as_bool).unwrap_or(false); + ui.label(RichText::new(&title).font(theme::font(24.0)).color(t.accent)); + match &path { + Some(p) => dim(ui, p), + None => dim(ui, "Not saved yet"), + } + if let Some(p) = &path { + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Copy path").clicked() { + ui.ctx().copy_text(p.clone()); + app.set_status("Copied the path"); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str) + && ui.button("Open file location").clicked() + { + app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/'))); + } + }); + } + ui.add_space(20.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(440.0); + if task( + ui, + Icon::Accessibility, + "Check for Issues", + "Inspect Presentation", + "Before you share it, check the presentation for things people with disabilities might find hard to read.", + true, + ) { + let _ = app.run("app.dialog", json!({"id": "accessibility"})); + } + let (detail, can_revert) = match (dirty, path.is_some()) { + (false, _) => ("There are no unsaved changes.", false), + (true, true) => ("This presentation has unsaved changes. Revert goes back to the saved file.", true), + (true, false) => ("This presentation has never been saved.", false), + }; + if task(ui, Icon::ResetSlide, "Revert", "Manage Presentation", detail, can_revert) { + let _ = app.run("file.revert", json!({})); + } + }); + ui.add_space(30.0); + ui.vertical(|ui| { + heading(ui, "Properties"); + let props = v.get("properties").cloned().unwrap_or_default(); + let size = v.get("bytes").and_then(Value::as_u64).map(format_bytes); + egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 7.0)).show(ui, |ui| { + let row = |ui: &mut Ui, k: &str, val: String| { + ui.label(RichText::new(k).color(t.text_dim)); + ui.label(val); + ui.end_row(); + }; + if let Some(s) = size { + row(ui, "Size", s); + } + row(ui, "Slides", v.get("slides").and_then(Value::as_u64).unwrap_or(0).to_string()); + row(ui, "Hidden slides", v.get("hiddenSlides").and_then(Value::as_u64).unwrap_or(0).to_string()); + for (i, (key, label, hint)) in PROPERTIES.iter().enumerate() { + let saved = props.get(*key).and_then(Value::as_str).unwrap_or("").to_string(); + let Some(draft) = bs.props.get_mut(i) else { continue }; + let l = ui.label(RichText::new(*label).color(t.text_dim)); + let id = ui.make_persistent_id(("backstage_prop", *key)); + let resp = ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(*hint).desired_width(300.0)).labelled_by(l.id); + if resp.lost_focus() && *draft != saved { + // Commit first: a click on another field takes the focus before this one is drawn. + let mut p = serde_json::Map::new(); + p.insert((*key).to_string(), json!(draft.clone())); + let _ = app.run("file.properties", Value::Object(p)); + } else if !resp.has_focus() && *draft != saved { + // Show the presentation's value unless the field is being edited (undo...). + draft.clone_from(&saved); + ui.ctx().request_repaint(); + } + ui.end_row(); + } + row(ui, "Location", path.clone().unwrap_or_else(|| "Not saved yet".into())); + }); + }); + }); +} + +/// Commits what the Info page's property fields hold and the presentation does not: a side-menu +/// action such as Save runs before the field that is being left is drawn again (#422). +pub(super) fn commit_props(app: &mut SlideApp, bs: &Backstage) { + if bs.page != Page::Info { + return; + } + let Ok(v) = app.session.execute("file.info", &json!({})) else { return }; + let mut p = serde_json::Map::new(); + for ((key, _, _), draft) in PROPERTIES.iter().zip(&bs.props) { + if v["properties"].get(*key).and_then(Value::as_str).unwrap_or("") != *draft { + p.insert((*key).to_string(), json!(draft)); + } + } + if !p.is_empty() { + let _ = app.run("file.properties", Value::Object(p)); + } +} + +/// `1234567` → `1.2 MB`. +pub fn format_bytes(b: u64) -> String { + match b { + 0..1024 => format!("{b} bytes"), + 1024..1_048_576 => format!("{:.1} KB", b as f64 / 1024.0), + _ => format!("{:.1} MB", b as f64 / 1_048_576.0), + } +} + +/// Save As: the file type, then the system Save dialog (desktop) or a download (web). A +/// presentation saved as .pptx or .deckcraft takes the new file; the other types are copies. +pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let (title, path) = app.session.active().map(|d| (d.title(), d.path.clone())).unwrap_or_default(); + ui.label(RichText::new(&title).font(theme::font(15.0)).color(t.text)); + dim(ui, path.as_deref().unwrap_or("Not saved yet")); + ui.add_space(14.0); + let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("backstage_save_type") + .width(340.0) + .selected_text(current) + .show_ui(ui, |ui| { + for (id, label, _) in SAVE_TYPES { + if ui.selectable_label(bs.file_type == id, label).clicked() { + bs.file_type = id; + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); + let web = app.services.pick_save.is_none(); + let label = if web { "Download" } else { "Save" }; + let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); + if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() { + let fmt = bs.file_type; + let done = if matches!(fmt, "pptx" | "deckcraft") { + let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + app.save_as_dialog(fmt); + web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before + } else { + app.export_as(fmt, json!({})) + }; + if done { + out.close = true; + } + } + }); + ui.add_space(8.0); + if let Some((_, _, detail)) = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type) { + dim(ui, detail); + } +} + +/// Account: the interface's appearance and what DeckCraft is. DeckCraft has no accounts, so +/// there is nothing to sign in to. +pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(340.0); + heading(ui, "Appearance"); + let dark = app.ui.brightness == theme::Brightness::Dark; + egui::ComboBox::from_id_salt("backstage_appearance") + .width(260.0) + .selected_text(if dark { "Dark" } else { "Light" }) + .show_ui(ui, |ui| { + for (on, label) in [(false, "Light"), (true, "Dark")] { + if ui.selectable_label(dark == on, label).clicked() { + let _ = app.run("view.dark", json!({"on": on})); + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); + ui.add_space(16.0); + heading(ui, "User Information"); + dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device."); + }); + ui.add_space(40.0); + ui.vertical(|ui| { + heading(ui, "Product Information"); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); + crate::dialogs::paint_logo(ui.painter(), r); + ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); + }); + dim(ui, &crate::dialogs::version_line()); + dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft."); + ui.add_space(14.0); + if task( + ui, + Icon::Help, + "About DeckCraft", + "About DeckCraft", + "The version, the people and models who built it, and where to find us.", + true, + ) { + let _ = app.run("app.about", json!({})); + } + ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); + }); + }); +} diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs new file mode 100644 index 000000000..9ebf15c57 --- /dev/null +++ b/crates/ui-egui/src/backstage/print.rs @@ -0,0 +1,370 @@ +use super::files::{place_row, square_button}; +use super::info::SAVE_TYPES; +use super::*; + +/// The slides the Print page prints, in order (0-based). +pub fn print_slides(doc: &deckcraft_model::Presentation, bs: &Backstage, current: usize) -> Result, String> { + let n = doc.slides.len(); + let mut v: Vec = match bs.print_scope { + PrintScope::All => (0..n).collect(), + PrintScope::Current => vec![current.min(n.saturating_sub(1))], + PrintScope::Custom => deckcraft_engine::cmd::file::parse_slide_range(&bs.print_range, n).map_err(|e| e.to_string())?, + }; + if !bs.print_hidden && bs.print_scope != PrintScope::Current { + v.retain(|i| doc.slides.get(*i).is_some_and(|s| !s.hidden)); + } + Ok(v) +} + +/// Handout slides per page for a `print_layout` id (`h3` → 3). +fn per_page(layout: &str) -> usize { + layout.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| matches!(n, 1 | 2 | 3 | 4 | 6 | 9)).unwrap_or(1) +} + +/// The `file.export` / `app.print` params for the Print page's settings. +pub fn print_params(bs: &Backstage, slides: &[usize]) -> Value { + let (layout, per) = match bs.print_layout { + "notes" => ("notes", 0), + l if l.starts_with('h') => ("handouts", per_page(l)), + _ => ("slides", 0), + }; + // The list is already filtered for hidden slides, so the PDF takes it as it is. + json!({ + "layout": layout, + "perPage": per, + "slides": slides, + "includeHidden": true, + "frame": bs.print_frame, + "paper": bs.print_paper, + "orientation": if bs.print_landscape { "landscape" } else { "portrait" }, + }) +} + +/// The printed page size in points: the slide size for full-page slides, else the chosen paper. +pub fn print_page_size(bs: &Backstage, doc: &deckcraft_model::Presentation) -> (f32, f32) { + if bs.print_layout == "slides" { + return (doc.slide_size.width as f32, doc.slide_size.height as f32); + } + let (w, h) = deckcraft_engine::cmd::file::paper_size(bs.print_paper, bs.print_landscape).unwrap_or((612.0, 792.0)); + (w as f32, h as f32) +} + +const LAYOUTS: [(&str, &str); 8] = [ + ("slides", "Full Page Slides"), + ("notes", "Notes Pages"), + ("h1", "Handouts (1 slide per page)"), + ("h2", "Handouts (2 slides per page)"), + ("h3", "Handouts (3 slides per page)"), + ("h4", "Handouts (4 slides per page)"), + ("h6", "Handouts (6 slides per page)"), + ("h9", "Handouts (9 slides per page)"), +]; + +/// Print: the Print button and settings on the left, a preview of each page on the right. +pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let Some((doc, current)) = app.session.active().map(|d| (d.doc.clone(), d.selection.slide)) else { + dim(ui, "No presentation is open."); + return; + }; + let slides = print_slides(&doc, bs, current); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + let ok = slides.as_ref().is_ok_and(|s| !s.is_empty()); + if square_button(ui, Icon::Print, "Print", ok).clicked() + && let Ok(s) = &slides + { + app.print_pdf(print_params(bs, s)); + } + ui.add_space(6.0); + dim( + ui, + if app.services.pick_save.is_some() { + "DeckCraft prints through a PDF: it opens in your PDF viewer, where you choose the printer." + } else { + "DeckCraft prints through a PDF: it downloads, ready to print from your browser or PDF viewer." + }, + ); + ui.add_space(14.0); + heading(ui, "Settings"); + let scope = match bs.print_scope { + PrintScope::All => "Print All Slides", + PrintScope::Current => "Print Current Slide", + PrintScope::Custom => "Custom Range", + }; + let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { + for (s, label) in + [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] + { + if ui.selectable_label(bs.print_scope == s, label).clicked() { + bs.print_scope = s; + bs.preview_page = 0; + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); + ui.add_space(4.0); + ui.horizontal(|ui| { + let l = ui.label("Slides:"); + let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text("e.g. 1-3, 5").desired_width(176.0)).labelled_by(l.id); + if r.changed() { + // Typing a range picks Custom Range, as in PowerPoint. + bs.print_scope = if bs.print_range.trim().is_empty() { PrintScope::All } else { PrintScope::Custom }; + bs.preview_page = 0; + } + }); + ui.add_space(4.0); + let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); + let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { + for (id, label) in LAYOUTS { + if ui.selectable_label(bs.print_layout == id, label).clicked() { + bs.print_layout = id; + bs.preview_page = 0; + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout")); + ui.add_space(4.0); + // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are + // for notes pages and handouts. + ui.add_enabled_ui(bs.print_layout != "slides", |ui| { + let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); + egui::ComboBox::from_id_salt("backstage_print_paper") + .width(240.0) + .selected_text(paper) + .show_ui(ui, |ui| { + for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { + if ui.selectable_label(bs.print_paper == id, label).clicked() { + bs.print_paper = id; + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); + ui.add_space(4.0); + let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; + egui::ComboBox::from_id_salt("backstage_print_orientation") + .width(240.0) + .selected_text(orient) + .show_ui(ui, |ui| { + for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { + if ui.selectable_label(bs.print_landscape == land, label).clicked() { + bs.print_landscape = land; + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); + }); + ui.add_space(4.0); + if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { + bs.preview_page = 0; + } + ui.checkbox(&mut bs.print_frame, "Frame Slides"); + ui.add_space(6.0); + if ui.link("Edit Header & Footer").clicked() { + let _ = app.run("app.dialog", json!({"id": "headerFooter"})); + } + }); + ui.add_space(20.0); + let (r, _) = ui.allocate_exact_size(vec2(1.0, 560.0), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + ui.add_space(20.0); + ui.vertical(|ui| match &slides { + Err(e) => dim(ui, e), + Ok(s) if s.is_empty() => dim(ui, "Nothing to print: every slide in the range is hidden."), + Ok(s) => preview(app, ui, bs, &doc, s), + }); + }); +} + +/// One printed page in miniature, with a pager under it. +fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_model::Presentation, slides: &[usize]) { + let t = Tokens::get(ui.ctx()); + let per = match bs.print_layout { + "slides" | "notes" => 1, + l => per_page(l), + }; + let pages: Vec<&[usize]> = slides.chunks(per.max(1)).collect(); + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(on_page) = pages.get(bs.preview_page).copied() else { return }; + // Full-page slides print on slide-shaped pages; notes and handouts on the chosen paper. + let (pw, ph) = print_page_size(bs, doc); + let avail_w = ui.available_width().clamp(200.0, 760.0); + let scale = (avail_w / pw.max(1.0)).min(520.0 / ph.max(1.0)); + let (area, _) = ui.allocate_exact_size(vec2(avail_w, ph * scale + 24.0), Sense::hover()); + ui.painter().rect_filled(area, CornerRadius::same(2), t.canvas); + let page = Rect::from_center_size(area.center(), vec2(pw * scale, ph * scale)); + let p = ui.painter(); + p.rect_filled(page.translate(vec2(2.0, 3.0)), 0.0, t.shadow); + p.rect_filled(page, 0.0, Color32::WHITE); + let aspect = (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + let mut budget = 4u32; + let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| { + let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32; + match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) { + Some(tex) => { + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => ui.ctx().request_repaint(), + } + if bs.print_frame || bs.print_layout != "slides" { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); + } + }; + match bs.print_layout { + "slides" => { + if let Some(i) = on_page.first() { + draw_slide(ui, *i, page); + } + } + "notes" => { + if let Some(i) = on_page.first() { + let w = page.width() * 0.75; + let r = Rect::from_min_size(pos2(page.center().x - w / 2.0, page.top() + page.height() * 0.08), vec2(w, w * aspect)); + draw_slide(ui, *i, r); + let notes = doc.slides.get(*i).map(|s| s.notes_text()).unwrap_or_default(); + let font = theme::font((11.0 * scale).clamp(5.0, 13.0)); + let text_rect = Rect::from_min_max( + pos2(page.left() + page.width() * 0.12, r.bottom() + 20.0 * scale), + page.max - vec2(page.width() * 0.12, 30.0 * scale), + ); + let galley = ui.painter().layout(notes, font, Color32::from_gray(30), text_rect.width()); + ui.painter().with_clip_rect(text_rect).galley(text_rect.min, galley, Color32::from_gray(30)); + } + } + l => { + let n = per_page(l); + let (cols, rows) = match n { + 1 => (1, 1), + 2 => (1, 2), + 3 => (1, 3), + 4 => (2, 2), + 6 => (2, 3), + _ => (3, 3), + }; + let inner = page.shrink2(vec2(page.width() * 0.08, page.height() * 0.07)); + // Three a page leaves lines for notes beside each slide, as PowerPoint's handouts do. + let slide_area = if n == 3 { Rect::from_min_size(inner.min, vec2(inner.width() * 0.5, inner.height())) } else { inner }; + let cell = vec2(slide_area.width() / cols as f32, slide_area.height() / rows as f32); + for (k, i) in on_page.iter().enumerate() { + let (c, r) = ((k % cols) as f32, (k / cols) as f32); + let cell_rect = Rect::from_min_size(slide_area.min + vec2(c * cell.x, r * cell.y), cell).shrink(cell.x.min(cell.y) * 0.08); + let w = cell_rect.width().min(cell_rect.height() / aspect.max(0.01)); + let sr = Rect::from_center_size(cell_rect.center(), vec2(w, w * aspect)); + draw_slide(ui, *i, sr); + if n == 3 { + for line in 1..=5 { + let y = sr.top() + sr.height() * line as f32 / 6.0; + ui.painter() + .line_segment([pos2(inner.center().x + 10.0, y), pos2(inner.right(), y)], Stroke::new(0.8, Color32::from_gray(150))); + } + } + } + } + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let prev = icon_button(ui, Icon::ChevronLeft, "Previous Page", bs.preview_page > 0); + if prev { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + ui.label(format!("{} of {}", bs.preview_page + 1, pages.len())); + if icon_button(ui, Icon::ChevronRight, "Next Page", bs.preview_page < last) { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); +} + +/// Export: tasks on the left (Create PDF Document, Create Handouts, Change File Type), the chosen +/// one's description and button on the right. PowerPoint's video, animated GIF and CD package +/// exports aren't offered. +pub(super) fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + for (task, icon, label) in [ + (ExportTask::Pdf, Icon::File, "Create PDF Document"), + (ExportTask::Handouts, Icon::Notes, "Create Handouts"), + (ExportTask::FileType, Icon::Export, "Change File Type"), + ] { + if place_row(ui, icon, label, bs.export == task, 244.0).clicked() { + bs.export = task; + } + } + }); + let (r, _) = ui.allocate_exact_size(vec2(1.0, 420.0), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + ui.add_space(24.0); + ui.vertical(|ui| { + ui.set_max_width(560.0); + match bs.export { + ExportTask::Pdf => { + ui.label(RichText::new("Create a PDF Document").font(theme::font(16.0)).color(t.text)); + bullets( + ui, + &["Keeps the layout, fonts and pictures", "Opens on any device with a PDF viewer", "The presentation keeps its own file"], + ); + ui.checkbox(&mut bs.pdf_hidden, "Include hidden slides"); + ui.add_space(10.0); + if square_button(ui, Icon::File, "Create PDF", true).clicked() { + app.export_as("pdf", json!({"includeHidden": bs.pdf_hidden})); + } + } + ExportTask::Handouts => { + ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); + bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); + let combo = egui::ComboBox::from_id_salt("backstage_handouts") + .width(240.0) + .selected_text(format!("{} slides per page", bs.handouts_per_page)) + .show_ui(ui, |ui| { + for n in [1u8, 2, 3, 4, 6, 9] { + if ui.selectable_label(bs.handouts_per_page == n, format!("{n} slides per page")).clicked() { + bs.handouts_per_page = n; + } + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page")); + ui.add_space(10.0); + if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() { + app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page})); + } + } + ExportTask::FileType => { + ui.label(RichText::new("Change File Type").font(theme::font(16.0)).color(t.text)); + ui.add_space(8.0); + for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { + let selected = bs.file_type == *id; + let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, CornerRadius::same(2), t.selected); + } else if resp.hovered() { + p.rect_filled(rect, CornerRadius::same(2), t.hover); + } + p.text(pos2(rect.left() + 12.0, rect.center().y - 8.0), Align2::LEFT_CENTER, *label, theme::font(13.0), t.text); + p.text(pos2(rect.left() + 12.0, rect.center().y + 9.0), Align2::LEFT_CENTER, *detail, theme::font(11.5), t.text_dim); + if resp.clicked() { + bs.file_type = id; + } + } + ui.add_space(12.0); + let png = bs.file_type == "png"; + if png { + ui.checkbox(&mut bs.png_transparent, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); + ui.add_space(8.0); + } + if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { + let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) }; + app.export_as(bs.file_type, extra); + } + } + } + }); + }); +} From 9c222be711f94fd0fc4ad8a332f161231ff95a71 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:52:33 +0000 Subject: [PATCH 48/90] refactor(engine): split cmd/design.rs into cmd/design/{colors,slide_background,slide_setup,master_view}.rs --- crates/engine/src/cmd/design.rs | 1397 +---------------- crates/engine/src/cmd/design/colors.rs | 255 +++ crates/engine/src/cmd/design/master_view.rs | 498 ++++++ .../engine/src/cmd/design/slide_background.rs | 298 ++++ crates/engine/src/cmd/design/slide_setup.rs | 336 ++++ 5 files changed, 1402 insertions(+), 1382 deletions(-) create mode 100644 crates/engine/src/cmd/design/colors.rs create mode 100644 crates/engine/src/cmd/design/master_view.rs create mode 100644 crates/engine/src/cmd/design/slide_background.rs create mode 100644 crates/engine/src/cmd/design/slide_setup.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 520b7b6a2..0a1202f74 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -16,6 +16,21 @@ use super::{ }; use crate::{Result, Session, Target}; +mod colors; +mod master_view; +mod slide_background; +mod slide_setup; + +use colors::{colors, effects, fonts, variant, variants}; +use master_view::{ + close_master, delete_layout, handout_placeholders, handout_view, in_handout, in_notes_master, insert_layout, insert_placeholder, master_view, + notes_placeholders, notes_view, placeholders, preserve, rename_layout, +}; +pub use master_view::{handout_placeholder, notes_placeholder}; +use slide_background::{background, hide_bg_graphics}; +pub use slide_setup::{civil, date_field_text, today}; +use slide_setup::{header_footer, scale_shapes, slide_size}; + pub fn specs() -> Vec { vec![ cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), @@ -288,1390 +303,8 @@ fn themes(_s: &mut Session, _p: &Value) -> Result { })) } -fn colors(s: &mut Session, p: &Value) -> Result { - let name_v = p.get("name"); - let colors_v = p.get("colors"); - if name_v.is_none() && colors_v.is_none() { - let sc = current_colors(s)?; - s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); - return ok(); - } - let given = match name_v { - None => None, - Some(Value::String(n)) => { - let n = n.trim(); - if n.is_empty() { - return Err(bad("design.colors", "name is empty")); - } - Some(n.to_string()) - } - Some(_) => return Err(bad("design.colors", "`name` must be a string")), - }; - let slots = parse_color_slots(colors_v)?; - let current = current_colors(s)?; - let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; - if next == current { - return ok(); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); - m.theme.colors = next; - Ok(()) - })?; - ok() -} - -fn variant(s: &mut Session, p: &Value) -> Result { - let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); - let v = deckcraft_model::theme::theme_variants(&m.theme); - m.theme.colors = - v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; - Ok(()) - })?; - Ok(json!({"variant": index})) -} - -fn variants(s: &mut Session, _p: &Value) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); - let v = deckcraft_model::theme::theme_variants(&th); - let current = v.iter().position(|c| c.colors == th.colors.colors); - Ok(json!({ - "theme": th.name, - "current": current, - "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), - })) -} - -fn current_colors(s: &Session) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) -} - -fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { - let mut colors = serde_json::Map::new(); - for slot in deckcraft_color::SchemeSlot::THEME { - colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); - } - json!({"name": sc.name, "colors": colors}) -} - -/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such -/// as `bg1` are rejected because `ColorScheme::set` ignores them. -fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { - let Some(v) = colors_v else { return Ok(None) }; - let Some(map) = v.as_object() else { - return Err(bad("design.colors", "`colors` must be an object")); - }; - if map.is_empty() { - return Err(bad("design.colors", "`colors` is empty")); - } - let mut slots = Vec::with_capacity(map.len()); - for (k, v) in map { - let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { - return Err(bad("design.colors", format!("unknown colour `{k}`"))); - }; - if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { - return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); - } - let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { - return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); - }; - slots.push((slot, rgb)); - } - Ok(Some(slots)) -} - -/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name -/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. -fn next_scheme( - current: &deckcraft_color::ColorScheme, - given: Option<&str>, - slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, -) -> Result { - let mut next = current.clone(); - if let Some(name) = given { - if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { - next = builtin; - } else if slots.is_none() { - return Err(bad("design.colors", format!("no colour scheme `{name}`"))); - } - next.name = name.to_string(); - } - if let Some(changes) = slots { - for (slot, rgb) in changes { - next.set(*slot, *rgb); - } - if given.is_none() { - next.name = "Custom".into(); - } - } - Ok(next) -} - -fn fonts(s: &mut Session, p: &Value) -> Result { - let name_v = p.get("name"); - let major_v = p.get("major"); - let minor_v = p.get("minor"); - if name_v.is_none() && major_v.is_none() && minor_v.is_none() { - let scheme = current_fonts(s)?; - s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) }); - return ok(); - } - let given = match name_v { - None => None, - Some(Value::String(n)) => { - let n = n.trim(); - if n.is_empty() { - return Err(bad("design.fonts", "name is empty")); - } - Some(n.to_string()) - } - Some(_) => return Err(bad("design.fonts", "`name` must be a string")), - }; - let major = face_param("major", major_v)?; - let minor = face_param("minor", minor_v)?; - let current = current_fonts(s)?; - let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?; - if next == current { - return ok(); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next; - Ok(()) - })?; - ok() -} - -fn current_fonts(s: &Session) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master")) -} - -fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value { - json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin}) -} - -/// `None` when the key is absent. A present face must be a non-blank string. -fn face_param(key: &str, v: Option<&Value>) -> Result> { - match v { - None => Ok(None), - Some(Value::String(s)) => { - let s = s.trim(); - if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) } - } - Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))), - } -} - -/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East -/// Asian, complex-script and per-script slots stay). The stored name is the given string when -/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name. -/// Faces without a name are a custom scheme: an omitted `minor` copies `major`. -fn next_font_scheme( - current: &deckcraft_model::theme::FontScheme, - given: Option<&str>, - major: Option<&str>, - minor: Option<&str>, -) -> Result { - let mut next = current.clone(); - if let Some(name) = given { - let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) { - next = builtin; - true - } else { - false - }; - if !replaced && major.is_none() && minor.is_none() { - return Err(bad("design.fonts", format!("no font scheme `{name}`"))); - } - if major.is_some() || minor.is_some() || !replaced { - next.name = name.to_string(); - } - } - if given.is_none() { - if let Some(face) = major { - next.major.latin = face.to_string(); - if minor.is_none() { - next.minor.latin = face.to_string(); - } - } - if let Some(face) = minor { - next.minor.latin = face.to_string(); - } - next.name = "Custom".into(); - } else { - if let Some(face) = major { - next.major.latin = face.to_string(); - } - if let Some(face) = minor { - next.minor.latin = face.to_string(); - } - } - Ok(next) -} - -fn effects(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?; - let scheme = builtin_format_schemes() - .into_iter() - .find(|f| f.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?; - let stored = scheme.name.clone(); - let same = { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme) - }; - if same { - return Ok(json!({"name": stored})); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme; - Ok(()) - })?; - Ok(json!({"name": stored})) -} - -fn background(s: &mut Session, p: &Value) -> Result { - if bool_or(p, "copy", false) { - return copy_slide_background(s); - } - let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); - let transparency = f64_param(p, "transparency"); - let reset = bool_or(p, "reset", false); - let color = color_param(p, "color"); - let style = usize_param(p, "style"); - let op = if reset { - BgOp::Set(None) - } else if let Some(mut c) = color { - if let Some(t) = transparency { - set_solid_transparency(&mut c, t); - } - BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) - } else if let Some(i) = style { - // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). - let slots = - [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; - let i = i.clamp(1, 12) - 1; - BgOp::Set(Some(Background::Ref { - idx: 1001 + (i / 4) as u32, - color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), - })) - } else if let Some(g) = p.get("gradient") { - let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; - g.rotate_with_shape = false; - BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) - } else if let Some(pt) = p.get("pattern") { - BgOp::Set(Some(Background::Fill { - fill: Fill::Pattern(deckcraft_model::style::PatternFill { - preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), - fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), - bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), - }), - })) - } else if p.get("picture").is_some() { - let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; - let ct = super::insert::content_type(&name, &bytes); - let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?; - let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; - if let Some(pp) = &props { - apply_picture_props(&mut pf, pp); - } - if let Some(t) = transparency { - set_picture_transparency(&mut pf, t); - } - BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) - } else if props.is_some() || transparency.is_some() { - BgOp::Adjust - } else { - return Err(bad( - "design.background", - "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", - )); - }; - let all = bool_or(p, "all", false); - let index = usize_param(p, "index"); - if matches!(op, BgOp::Adjust) { - let next = { - let st = s.doc()?; - let current = background_of(&st.doc, &st.selection, index); - let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); - let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); - if props.is_some() && !picture { - return Err(bad("design.background", "picture options need a picture background")); - } - if transparency.is_some() && !picture && !solid { - return Err(bad("design.background", "transparency needs a solid or picture background")); - } - let mut next = current.clone(); - if let Some(bg) = next.as_mut() { - adjust_background(bg, props.as_ref(), transparency); - } - if next == current { None } else { Some(next) } - }; - let Some(next) = next else { return ok() }; - s.edit(|doc, sel| { - write_background(doc, sel, index, false, next); - Ok(()) - })?; - return ok(); - } - let BgOp::Set(bg) = op else { return ok() }; - let unchanged = { - let st = s.doc()?; - backgrounds_already(&st.doc, &st.selection, index, all, &bg) - }; - if unchanged { - return ok(); - } - s.edit(|doc, sel| { - write_background(doc, sel, index, all, bg); - Ok(()) - })?; - ok() -} - -enum BgOp { - Set(Option), - Adjust, -} - -fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { - match sel.target { - Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()), - Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), - } -} - -fn used_master_indices(doc: &Presentation) -> BTreeSet { - let fallback = (!doc.masters.is_empty()).then_some(0); - doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect() -} - -fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { - match sel.target { - Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg), - Target::Slides => { - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)); - if all && bg.is_some() { - let masters = used_master_indices(doc); - let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg)); - let layouts_match = !masters.is_empty() - && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none()))); - slides_match && masters_match && layouts_match - } else { - slides_match - } - } - } -} - -fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { - match sel.target { - Target::Master { master } => { - if let Some(m) = doc.masters.get_mut(master) { - Arc::make_mut(m).background = bg; - } - } - Target::Layout { master, layout } => { - if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { - l.background = bg; - } - } - t @ (Target::Handout | Target::NotesMaster) => { - if let Some(m) = crate::page_master_mut(doc, t) { - Arc::make_mut(m).background = bg; - } - } - Target::Slides => { - if all && bg.is_some() { - for idx in used_master_indices(doc) { - if let Some(m) = doc.masters.get_mut(idx) { - let m = Arc::make_mut(m); - m.background = bg.clone(); - for l in &mut m.layouts { - l.background = None; - } - } - } - } - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - for i in list { - if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).background = bg.clone(); - } - } - } - } -} - -fn copy_slide_background(s: &mut Session) -> Result { - let bg = { - let st = s.doc()?; - if !matches!(st.selection.target, Target::Slides) { - return Err(bad("design.background", "copy applies to slides")); - } - let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); - let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg); - let masters_match = if bg.is_some() { - let masters = used_master_indices(&st.doc); - !masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg)) - } else { - true - }; - if slides_match && masters_match { - return ok(); - } - bg - }; - s.edit(|doc, sel| { - write_background(doc, sel, None, true, bg); - Ok(()) - })?; - ok() -} - -fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { - match bg { - Background::Fill { fill: Fill::Picture(pf) } => { - if let Some(pp) = props { - apply_picture_props(pf, pp); - } - if let Some(t) = transparency { - set_picture_transparency(pf, t); - } - } - Background::Fill { fill: Fill::Solid { color } } => { - if let Some(t) = transparency { - set_solid_transparency(color, t); - } - } - _ => {} - } -} - -fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { - let a = 1.0 - t.clamp(0.0, 1.0); - pf.alpha = (a < 1.0).then_some(a); -} - -fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { - color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); - let t = t.clamp(0.0, 1.0); - if t > 0.0 { - color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); - } -} - -/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). -fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { - use deckcraft_model::style::PictureMode; - let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { - pf.upright = !up; - } - match pp.get("tile").and_then(Value::as_bool) { - Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { - pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; - } - Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), - _ => {} - } - match &mut pf.mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - if let Some(v) = f("offsetX") { - *tx = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("offsetY") { - *ty = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("scaleX") { - *sx = v.clamp(0.01, 10.0); - } - if let Some(v) = f("scaleY") { - *sy = v.clamp(0.01, 10.0); - } - if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { - *flip = m.to_string(); - } - if let Some(a) = - pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) - { - *align = a.to_string(); - } - } - PictureMode::Stretch { fill_rect } => { - if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { - for (slot, v) in fill_rect.iter_mut().zip(arr) { - if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { - *slot = v.clamp(-10.0, 0.99); - } - } - } - } - } -} - -fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, sel| { - if let Some(sl) = doc.slides.get_mut(sel.slide) { - let sl = Arc::make_mut(sl); - sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes); - } - Ok(()) - })?; - ok() -} - -fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { - for sh in shapes { - if let Some(x) = sh.xfrm.as_mut() { - x.x = x.x * sx + ox; - x.y = x.y * sy + oy; - x.w *= sx; - x.h *= sy; - } - if let Some(t) = sh.text.as_mut() { - deckcraft_model::edit::format_all(t, &|r| { - if let Some(sz) = r.size.as_mut() { - *sz = (*sz * k).max(1.0); - } - }); - } - } -} - -fn slide_size(s: &mut Session, p: &Value) -> Result { - let size = if let Some(pre) = str_param(p, "preset") { - let k = pre.to_ascii_lowercase(); - let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k)); - let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?; - Size::new(*w, *h) - } else { - Size::new( - f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0), - f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0), - ) - }; - let size = match str_param(p, "orientation") { - Some("portrait") if size.width > size.height => Size::new(size.height, size.width), - Some("landscape") if size.height > size.width => Size::new(size.height, size.width), - Some("portrait" | "landscape") | None => size, - Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))), - }; - let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); - s.edit(|doc, _| { - let old = doc.slide_size; - let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); - let (sx, sy, k, ox, oy) = match mode.as_str() { - "none" => (1.0, 1.0, 1.0, 0.0, 0.0), - "maximize" => { - let k = sx.max(sy); - (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) - } - _ => { - let k = sx.min(sy); - (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) - } - }; - // Masters and layouts stretch to the new size; slides scale by mode. - let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); - for m in &mut doc.masters { - let m = Arc::make_mut(m); - scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); - for l in &mut m.layouts { - scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); - } - } - for sl in &mut doc.slides { - scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); - } - doc.slide_size = size; - Ok(()) - })?; - Ok(json!({"w": size.width, "h": size.height})) -} - -fn header_footer(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, sel| { - let hf = &mut doc.header_footer; - if let Some(v) = bool_param(p, "date") { - hf.date = v; - } - if let Some(v) = str_param(p, "dateText") { - hf.date_text = v.into(); - } - if let Some(v) = str_param(p, "dateFormat") { - // Refused rather than ignored: a format that renders nothing would leave the - // placeholder showing its saved text for ever. - if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { - return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); - } - hf.date_format = v.into(); - } - if let Some(v) = bool_param(p, "slideNumber") { - hf.slide_number = v; - } - if let Some(v) = bool_param(p, "footer") { - hf.footer = v; - } - if let Some(v) = str_param(p, "footerText") { - hf.footer_text = v.into(); - hf.footer = hf.footer || !v.is_empty(); - } - if let Some(v) = bool_param(p, "hideOnTitle") { - hf.hide_on_title = v; - } - let hf = hf.clone(); - let date_given = str_param(p, "dateText").is_some(); - let format_given = str_param(p, "dateFormat").is_some(); - let all = bool_or(p, "all", true); - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; - for i in list { - let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; - let lay = doc.layout(layout).map(|(_, l)| l.clone()); - let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title); - let want = |k: PhType| { - !(hf.hide_on_title && is_title) - && match k { - PhType::Date => hf.date, - PhType::SlideNum => hf.slide_number, - PhType::Footer => hf.footer, - _ => false, - } - }; - let mut new_ids = vec![]; - for _ in 0..3 { - new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?)); - } - let Some(sl) = doc.slides.get_mut(i) else { continue }; - let sl = Arc::make_mut(sl); - for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { - let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); - match (has, want(k)) { - (Some(pos), false) => { - sl.shapes.remove(pos); - } - (None, true) => { - let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); - let Some(lp) = lp else { continue }; - let body = match k { - PhType::SlideNum => TextBody { - paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { - text: "‹#›".into(), - props: Default::default(), - kind: RunKind::Field { field: "slidenum".into() }, - }], - ..Default::default() - }], - ..Default::default() - }, - PhType::Date if hf.date_text.is_empty() => TextBody { - paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { - text: date_now(&hf.date_format), - props: Default::default(), - kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, - }], - ..Default::default() - }], - ..Default::default() - }, - PhType::Date => TextBody::from_text(&hf.date_text), - _ => TextBody::from_text(&hf.footer_text), - }; - sl.shapes.push(Shape { - id: new_ids.get(n).copied().unwrap_or_default(), - name: lp.name.clone(), - ph: lp.ph.clone(), - text: Some(body), - ..Default::default() - }); - } - (Some(pos), true) if k == PhType::Footer => { - if let Some(sh) = sl.shapes.get_mut(pos) { - sh.text = Some(TextBody::from_text(&hf.footer_text)); - } - } - (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { - if let Some(sh) = sl.shapes.get_mut(pos) { - set_date(sh, &hf.date_text, &hf.date_format); - } - } - _ => {} - } - } - } - Ok(()) - })?; - ok() -} - -/// The date field type `format` names, `datetime1` when it is empty or unknown. -fn date_kind(format: &str) -> &str { - if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } -} - -/// The saved text of a date field of `format`, as it shows now. -fn date_now(format: &str) -> String { - let kind = date_kind(format); - deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) -} - -/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty -/// `fixed`, at an automatic date field (one it already has keeps its format). -fn set_date(sh: &mut Shape, fixed: &str, format: &str) { - let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); - let field_now = runs().find_map(|r| match &r.kind { - RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), - _ => None, - }); - let has_field = field_now.is_some(); - let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); - let want = date_kind(format); - let (text, kind) = match fixed { - // Automatic: keep the field, but follow a format that has changed. - "" if field_now.as_deref() == Some(want) => return, - "" if has_field && format.is_empty() => return, - "" => (date_now(format), RunKind::Field { field: want.into() }), - _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, - _ => (fixed.to_string(), RunKind::Text), - }; - let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() }; - sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() }); -} - -/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date -/// field. Empty where there is no clock (wasm). -pub fn today() -> String { - date_field_text("datetime1") -} - -/// What date field `kind` shows now (empty if unknown or there is no clock). -pub fn date_field_text(kind: &str) -> String { - use deckcraft_text::datetime::{DateTime, field_text}; - DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() -} - -/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. -pub fn civil(z: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 - // years (146_097 days). chrono converts the day within its cycle and whole cycles go back - // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and - // 0..146_097 days from it always fits. - let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - None => (1970, 1, 1), - } -} - -fn master_view(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); - // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects - // that layout. With neither key, follow the current slide's layout. - let layout = if p.get("master").is_some() && p.get("layout").is_none() { - None - } else { - usize_param(p, "layout") - .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) - }; - let target = match layout { - Some(l) => Target::Layout { master: mi, layout: l }, - None => Target::Master { master: mi }, - }; - s.select(|_, sel| { - sel.target = target; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(serde_json::to_value(target).unwrap_or_default()) -} - -fn in_handout(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) } -} - -/// The four handout-master placeholders PowerPoint offers, in a page of `size`. -pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { - let (w, h) = (size.width, size.height); - let (name, idx, x, y) = match kind { - PhType::Header => ("Header Placeholder", 0, 0.0, 0.0), - PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0), - PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95), - _ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95), - }; - let mut text = TextBody::default(); - if let Some(p) = text.paragraphs.first_mut() - && matches!(kind, PhType::Date | PhType::SlideNum) - { - p.props.align = Some(deckcraft_model::text::Align::Right); - } - Shape { - id, - name: format!("{name} {}", idx + 1), - xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)), - ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }), - text: Some(text), - ..Default::default() - } -} - -const HANDOUT_KINDS: [(&str, PhType); 4] = - [("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)]; - -fn handout_view(s: &mut Session, _p: &Value) -> Result { - let created = s.doc()?.doc.handout_master.is_none(); - if created { - s.edit(|doc, _| { - let mut m = deckcraft_model::Master { - name: "Handout Master".into(), - theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), - color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), - ..Default::default() - }; - let size = doc.notes_size; - for (_, k) in HANDOUT_KINDS { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?); - m.shapes.push(handout_placeholder(k, size, id)); - } - doc.handout_master = Some(Arc::new(m)); - Ok(()) - })?; - } - s.select(|_, sel| { - sel.target = Target::Handout; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(json!({"created": created})) -} - -fn handout_placeholders(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, _| { - let size = doc.notes_size; - let mut ids = vec![]; - for (key, _) in HANDOUT_KINDS { - let id = match p.get(key).and_then(Value::as_bool) { - Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)), - None => None, - }; - ids.push(id); - } - let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?); - for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) { - let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue }; - let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); - if on && !has { - m.shapes.push(handout_placeholder(kind, size, id)); - } else if !on { - m.shapes.retain(|x| x.ph_type() != Some(kind)); - } - } - Ok(()) - })?; - let m = s.doc()?.doc.handout_master.clone(); - let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k))); - Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) -} - -fn in_notes_master(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } -} - -const NOTES_KINDS: [(&str, PhType); 6] = [ - ("header", PhType::Header), - ("date", PhType::Date), - ("slideImage", PhType::SlideImage), - ("body", PhType::Body), - ("footer", PhType::Footer), - ("pageNumber", PhType::SlideNum), -]; - -/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. -pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { - let (w, h) = (size.width, size.height); - match kind { - PhType::SlideImage => Shape { - id, - name: "Slide Image Placeholder 3".into(), - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), - ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), - fill: Some(Fill::None), - line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), - ..Default::default() - }, - PhType::Body => { - let mut text = TextBody::default(); - text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; - Shape { - id, - name: "Notes Placeholder 4".into(), - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), - ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), - text: Some(text), - ..Default::default() - } - } - _ => { - let mut s = handout_placeholder(kind, size, id); - if let Some(ph) = s.ph.as_mut() { - ph.idx = match kind { - PhType::Header => 0, - PhType::Date => 1, - PhType::Footer => 4, - _ => 5, - }; - } - s - } - } -} - -fn notes_view(s: &mut Session, _p: &Value) -> Result { - let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); - if created { - s.edit(|doc, _| { - let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { - name: "Notes Master".into(), - theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), - color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), - ..Default::default() - }); - let size = doc.notes_size; - for (_, k) in NOTES_KINDS { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?); - m.shapes.push(notes_placeholder(k, size, id)); - } - doc.notes_master = Some(Arc::new(m)); - Ok(()) - })?; - } - s.select(|_, sel| { - sel.target = Target::NotesMaster; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(json!({"created": created})) -} - -fn notes_placeholders(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, _| { - let size = doc.notes_size; - let ids: Vec = - NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; - let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); - for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { - let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; - let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); - if on && !has { - m.shapes.push(notes_placeholder(kind, size, id)); - } else if !on { - m.shapes.retain(|x| x.ph_type() != Some(kind)); - } - } - Ok(()) - })?; - let m = s.doc()?.doc.notes_master.clone(); - let mut out = serde_json::Map::new(); - for (key, kind) in NOTES_KINDS { - out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); - } - Ok(Value::Object(out)) -} - -fn close_master(s: &mut Session, _p: &Value) -> Result { - s.select(|_, sel| { - sel.target = Target::Slides; - sel.shapes.clear(); - sel.text = None; - })?; - ok() -} - -fn insert_layout(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").unwrap_or("Custom Layout").to_string(); - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); - let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); - let size = doc.slide_size; - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); - let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { - t.id = tid; - t.text = Some(TextBody::default()); - t - }); - let _ = size; - let at = match sel.target { - Target::Layout { layout, .. } => layout + 1, - _ => m.layouts.len(), - }; - m.layouts.insert( - at.min(m.layouts.len()), - deckcraft_model::Layout { - id, - name, - kind: deckcraft_model::LayoutType::Custom, - shapes: title.into_iter().collect(), - background: None, - show_master_shapes: true, - preserve: true, - color_map: None, - raw_ext: None, - }, - ); - sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; - Ok(json!({"id": id})) - }) -} - -fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { - let st = s.doc()?; - let (m, l) = match st.selection.target { - Target::Layout { master, layout } => (master, layout), - _ => (master_index(&st.doc, &st.selection), 0), - }; - Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l))) -} - -fn rename_layout(s: &mut Session, p: &Value) -> Result { - let (mi, li) = layout_target(s, p, "master.renameLayout")?; - let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); - s.edit(|doc, _| { - let l = - doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; - l.name = name; - Ok(()) - })?; - ok() -} - -fn delete_layout(s: &mut Session, p: &Value) -> Result { - let (mi, li) = layout_target(s, p, "master.deleteLayout")?; - s.edit(|doc, sel| { - let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; - if doc.slides.iter().any(|x| x.layout == id) { - return Err(bad("master.deleteLayout", "slides use this layout")); - } - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); - if m.layouts.len() <= 1 { - return Err(bad("master.deleteLayout", "a master needs at least one layout")); - } - m.layouts.remove(li); - sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; - Ok(()) - })?; - ok() -} - -fn insert_placeholder(s: &mut Session, p: &Value) -> Result { - let kind = match str_param(p, "kind").unwrap_or("content") { - "text" => PhType::Body, - "picture" => PhType::Picture, - "chart" => PhType::Chart, - "table" => PhType::Table, - "media" => PhType::Media, - _ => PhType::Obj, - }; - if !matches!(s.doc()?.selection.target, Target::Layout { .. }) { - return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view")); - } - let size = s.doc()?.doc.slide_size; - let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); - let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; - let shape = Shape { - xfrm: Some(rect), - ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), - text: Some(TextBody::default()), - ..Default::default() - }; - let id = super::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; - Ok(json!({"id": id})) -} - -fn preserve(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let (on_master, mi, li, current) = match st.selection.target { - Target::Master { master } => { - let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; - (true, master, 0, on) - } - Target::Layout { master, layout } => { - let on = st - .doc - .masters - .get(master) - .and_then(|m| m.layouts.get(layout)) - .map(|l| l.preserve) - .ok_or_else(|| bad("master.preserve", "no such layout"))?; - (false, master, layout, on) - } - Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")), - }; - let next = bool_param(p, "preserve").unwrap_or(!current); - if next == current { - return Ok(json!({"preserve": next})); - } - s.edit(|doc, _| { - if on_master { - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); - m.preserve = next; - } else { - let l = - doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; - l.preserve = next; - } - Ok(()) - })?; - Ok(json!({"preserve": next})) -} - -fn placeholders(s: &mut Session, p: &Value) -> Result { - let title = bool_param(p, "title"); - let footers = bool_param(p, "footers"); - if title.is_none() && footers.is_none() { - return Err(bad("master.placeholders", "give title or footers")); - } - let st = s.doc()?; - if matches!(st.selection.target, Target::Slides) { - return Err(bad("master.placeholders", "open Slide Master view")); - } - let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); - let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); - let title_changes = title.is_some_and(|want| want != has_title); - let footer_changes = footers.is_some_and(|want| want != has_footers); - if !title_changes && !footer_changes { - return Ok(json!({"title": has_title, "footers": has_footers})); - } - let mut add = Vec::new(); - if title == Some(true) && !has_title { - add.push(PhType::Title); - } - if footers == Some(true) && !has_footers { - add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); - } - let size = st.doc.slide_size; - s.edit(|doc, sel| { - let mut ids = Vec::with_capacity(add.len()); - for _ in 0..add.len() { - ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?)); - } - let prototypes = match sel.target { - Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), - Target::Slides => Vec::new(), - Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")), - }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; - if title == Some(false) { - shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); - } - if footers == Some(false) { - shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); - } - for (kind, id) in add.into_iter().zip(ids) { - let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); - let proto = prototypes.iter().find(|sh| match kind { - PhType::Title => sh.ph_type().is_some_and(PhType::is_title), - other => sh.ph_type() == Some(other), - }); - let shape = match proto { - Some(src) => retarget_placeholder(src.clone(), id, idx), - None => synthesized_placeholder(kind, id, idx, size), - }; - shapes.push(shape); - } - Ok(()) - })?; - let st = s.doc()?; - let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); - let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); - Ok(json!({"title": title, "footers": footers})) -} - -fn preferred_ph_idx(kind: PhType) -> u32 { - match kind { - PhType::Title | PhType::CtrTitle => 0, - PhType::Date => 10, - PhType::Footer => 11, - PhType::SlideNum => 12, - _ => 1, - } -} - -fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { - let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); - if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } -} - -/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide -/// number keeps its `‹#›` field. -fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { - sh.id = id; - if let Some(ph) = sh.ph.as_mut() { - ph.idx = idx; - } - let slide_num = sh.ph_type() == Some(PhType::SlideNum); - if slide_num { - let paras = vec![slide_number_paragraph()]; - if let Some(text) = sh.text.as_mut() { - text.paragraphs = paras; - } else { - sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); - } - } else if let Some(text) = sh.text.as_mut() { - text.paragraphs.clear(); - } - sh -} - -fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { - let (name, text) = match kind { - PhType::Date => ("Date Placeholder", TextBody::default()), - PhType::Footer => ("Footer Placeholder", TextBody::default()), - PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), - _ => ("Title", TextBody::default()), - }; - Shape { - id, - name: name.into(), - xfrm: Some(placeholder_rect(kind, size)), - ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), - text: Some(text), - ..Default::default() - } -} - -/// Standard placeholder boxes at 960×540, scaled to the slide size. -fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { - let sx = size.width / 960.0; - let sy = size.height / 540.0; - let (x, y, w, h) = match kind { - PhType::Date => (60.0, 500.0, 216.0, 28.0), - PhType::Footer => (318.0, 500.0, 324.0, 28.0), - PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), - _ => (60.0, 29.0, 840.0, 104.0), - }; - deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) -} - -fn slide_number_paragraph() -> deckcraft_model::Paragraph { - deckcraft_model::Paragraph { - runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], - ..Default::default() - } -} - #[cfg(test)] mod tests; #[cfg(test)] mod browse_theme_tests; - -#[cfg(test)] -mod header_footer_date_tests { - use deckcraft_model::PhType; - use deckcraft_model::text::RunKind; - use serde_json::json; - - use crate::Session; - - /// The date field of the Date placeholders of every slide: (saved text, field type). - fn dates(s: &Session) -> Vec<(String, Option)> { - let d = &s.doc().unwrap().doc; - d.slides - .iter() - .flat_map(|sl| sl.shapes.iter()) - .filter(|sh| sh.ph_type() == Some(PhType::Date)) - .flat_map(|sh| sh.text.iter()) - .flat_map(|t| t.paragraphs.iter()) - .flat_map(|p| p.runs.iter()) - .map(|r| { - let kind = match &r.kind { - RunKind::Field { field } => Some(field.clone()), - _ => None, - }; - (r.text.clone(), kind) - }) - .collect() - } - - fn sample(kind: &str) -> String { - let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); - deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") - } - - /// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field. - /// The clock is read before and after the command, so a second ticking over between the - /// command and the read cannot fail the comparison. Returns the run text. - fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String { - let before = sample(kind); - s.execute("design.headerFooter", ¶ms).unwrap(); - let after = sample(kind); - let got = dates(s); - assert_eq!(got.len(), 1, "{got:?}"); - assert_eq!(got[0].1.as_deref(), Some(kind)); - assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}"); - got[0].0.clone() - } - - /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` - /// existed for this and was never read: the placeholder was always `datetime1`, and a - /// `dateFormat` parameter was accepted and silently dropped. - #[test] - fn the_automatic_date_takes_the_format_it_is_given() { - let mut s = Session::with_new(); - - // Default: the short date, as before. - run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1"); - - // A format: the field type changes and the saved text is that format's. - run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5"); - assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); - - // A time format works the same way, and these two differ, so the format is really read. - let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); - assert_ne!(time, sample("datetime5")); - - // Asking again changes nothing (and does not rewrite the run). - let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); - assert_eq!(again, time); - - // A fixed date still wins: plain text, no field. - s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); - assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); - - // Back to automatic: the remembered format comes back, not datetime1. - run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11"); - - // An unknown format is refused rather than ignored, and changes nothing. - for bad in ["datetime0", "datetime14", "nonsense", ""] { - assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); - } - assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); - let got = dates(&s); - assert_eq!(got.len(), 1, "{got:?}"); - assert_eq!(got[0].1.as_deref(), Some("datetime11")); - } -} diff --git a/crates/engine/src/cmd/design/colors.rs b/crates/engine/src/cmd/design/colors.rs new file mode 100644 index 000000000..9ce4f5440 --- /dev/null +++ b/crates/engine/src/cmd/design/colors.rs @@ -0,0 +1,255 @@ +use super::*; + +pub(super) fn colors(s: &mut Session, p: &Value) -> Result { + let name_v = p.get("name"); + let colors_v = p.get("colors"); + if name_v.is_none() && colors_v.is_none() { + let sc = current_colors(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.colors", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.colors", "`name` must be a string")), + }; + let slots = parse_color_slots(colors_v)?; + let current = current_colors(s)?; + let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; + if next == current { + return ok(); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); + m.theme.colors = next; + Ok(()) + })?; + ok() +} + +pub(super) fn variant(s: &mut Session, p: &Value) -> Result { + let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); + let v = deckcraft_model::theme::theme_variants(&m.theme); + m.theme.colors = + v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; + Ok(()) + })?; + Ok(json!({"variant": index})) +} + +pub(super) fn variants(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); + let v = deckcraft_model::theme::theme_variants(&th); + let current = v.iter().position(|c| c.colors == th.colors.colors); + Ok(json!({ + "theme": th.name, + "current": current, + "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), + })) +} + +fn current_colors(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) +} + +fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { + let mut colors = serde_json::Map::new(); + for slot in deckcraft_color::SchemeSlot::THEME { + colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); + } + json!({"name": sc.name, "colors": colors}) +} + +/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such +/// as `bg1` are rejected because `ColorScheme::set` ignores them. +fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { + let Some(v) = colors_v else { return Ok(None) }; + let Some(map) = v.as_object() else { + return Err(bad("design.colors", "`colors` must be an object")); + }; + if map.is_empty() { + return Err(bad("design.colors", "`colors` is empty")); + } + let mut slots = Vec::with_capacity(map.len()); + for (k, v) in map { + let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { + return Err(bad("design.colors", format!("unknown colour `{k}`"))); + }; + if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { + return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); + } + let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { + return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); + }; + slots.push((slot, rgb)); + } + Ok(Some(slots)) +} + +/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name +/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. +fn next_scheme( + current: &deckcraft_color::ColorScheme, + given: Option<&str>, + slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { + next = builtin; + } else if slots.is_none() { + return Err(bad("design.colors", format!("no colour scheme `{name}`"))); + } + next.name = name.to_string(); + } + if let Some(changes) = slots { + for (slot, rgb) in changes { + next.set(*slot, *rgb); + } + if given.is_none() { + next.name = "Custom".into(); + } + } + Ok(next) +} + +pub(super) fn fonts(s: &mut Session, p: &Value) -> Result { + let name_v = p.get("name"); + let major_v = p.get("major"); + let minor_v = p.get("minor"); + if name_v.is_none() && major_v.is_none() && minor_v.is_none() { + let scheme = current_fonts(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.fonts", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.fonts", "`name` must be a string")), + }; + let major = face_param("major", major_v)?; + let minor = face_param("minor", minor_v)?; + let current = current_fonts(s)?; + let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?; + if next == current { + return ok(); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next; + Ok(()) + })?; + ok() +} + +fn current_fonts(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master")) +} + +fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value { + json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin}) +} + +/// `None` when the key is absent. A present face must be a non-blank string. +fn face_param(key: &str, v: Option<&Value>) -> Result> { + match v { + None => Ok(None), + Some(Value::String(s)) => { + let s = s.trim(); + if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) } + } + Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))), + } +} + +/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East +/// Asian, complex-script and per-script slots stay). The stored name is the given string when +/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name. +/// Faces without a name are a custom scheme: an omitted `minor` copies `major`. +fn next_font_scheme( + current: &deckcraft_model::theme::FontScheme, + given: Option<&str>, + major: Option<&str>, + minor: Option<&str>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) { + next = builtin; + true + } else { + false + }; + if !replaced && major.is_none() && minor.is_none() { + return Err(bad("design.fonts", format!("no font scheme `{name}`"))); + } + if major.is_some() || minor.is_some() || !replaced { + next.name = name.to_string(); + } + } + if given.is_none() { + if let Some(face) = major { + next.major.latin = face.to_string(); + if minor.is_none() { + next.minor.latin = face.to_string(); + } + } + if let Some(face) = minor { + next.minor.latin = face.to_string(); + } + next.name = "Custom".into(); + } else { + if let Some(face) = major { + next.major.latin = face.to_string(); + } + if let Some(face) = minor { + next.minor.latin = face.to_string(); + } + } + Ok(next) +} + +pub(super) fn effects(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?; + let scheme = builtin_format_schemes() + .into_iter() + .find(|f| f.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?; + let stored = scheme.name.clone(); + let same = { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme) + }; + if same { + return Ok(json!({"name": stored})); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme; + Ok(()) + })?; + Ok(json!({"name": stored})) +} diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs new file mode 100644 index 000000000..f72aa22d0 --- /dev/null +++ b/crates/engine/src/cmd/design/master_view.rs @@ -0,0 +1,498 @@ +use super::*; + +pub(super) fn master_view(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); + // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects + // that layout. With neither key, follow the current slide's layout. + let layout = if p.get("master").is_some() && p.get("layout").is_none() { + None + } else { + usize_param(p, "layout") + .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) + }; + let target = match layout { + Some(l) => Target::Layout { master: mi, layout: l }, + None => Target::Master { master: mi }, + }; + s.select(|_, sel| { + sel.target = target; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(serde_json::to_value(target).unwrap_or_default()) +} + +pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) } +} + +/// The four handout-master placeholders PowerPoint offers, in a page of `size`. +pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + let (name, idx, x, y) = match kind { + PhType::Header => ("Header Placeholder", 0, 0.0, 0.0), + PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0), + PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95), + _ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95), + }; + let mut text = TextBody::default(); + if let Some(p) = text.paragraphs.first_mut() + && matches!(kind, PhType::Date | PhType::SlideNum) + { + p.props.align = Some(deckcraft_model::text::Align::Right); + } + Shape { + id, + name: format!("{name} {}", idx + 1), + xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)), + ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +const HANDOUT_KINDS: [(&str, PhType); 4] = + [("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)]; + +pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.handout_master.is_none(); + if created { + s.edit(|doc, _| { + let mut m = deckcraft_model::Master { + name: "Handout Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }; + let size = doc.notes_size; + for (_, k) in HANDOUT_KINDS { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?); + m.shapes.push(handout_placeholder(k, size, id)); + } + doc.handout_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::Handout; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let mut ids = vec![]; + for (key, _) in HANDOUT_KINDS { + let id = match p.get(key).and_then(Value::as_bool) { + Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)), + None => None, + }; + ids.push(id); + } + let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?); + for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) { + let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(handout_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.handout_master.clone(); + let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k))); + Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) +} + +pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } +} + +const NOTES_KINDS: [(&str, PhType); 6] = [ + ("header", PhType::Header), + ("date", PhType::Date), + ("slideImage", PhType::SlideImage), + ("body", PhType::Body), + ("footer", PhType::Footer), + ("pageNumber", PhType::SlideNum), +]; + +/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. +pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + match kind { + PhType::SlideImage => Shape { + id, + name: "Slide Image Placeholder 3".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), + fill: Some(Fill::None), + line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), + ..Default::default() + }, + PhType::Body => { + let mut text = TextBody::default(); + text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; + Shape { + id, + name: "Notes Placeholder 4".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } + } + _ => { + let mut s = handout_placeholder(kind, size, id); + if let Some(ph) = s.ph.as_mut() { + ph.idx = match kind { + PhType::Header => 0, + PhType::Date => 1, + PhType::Footer => 4, + _ => 5, + }; + } + s + } + } +} + +pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); + if created { + s.edit(|doc, _| { + let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { + name: "Notes Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }); + let size = doc.notes_size; + for (_, k) in NOTES_KINDS { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?); + m.shapes.push(notes_placeholder(k, size, id)); + } + doc.notes_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::NotesMaster; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let ids: Vec = + NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; + let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); + for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { + let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(notes_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.notes_master.clone(); + let mut out = serde_json::Map::new(); + for (key, kind) in NOTES_KINDS { + out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); + } + Ok(Value::Object(out)) +} + +pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + sel.target = Target::Slides; + sel.shapes.clear(); + sel.text = None; + })?; + ok() +} + +pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("Custom Layout").to_string(); + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); + let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); + let size = doc.slide_size; + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); + let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { + t.id = tid; + t.text = Some(TextBody::default()); + t + }); + let _ = size; + let at = match sel.target { + Target::Layout { layout, .. } => layout + 1, + _ => m.layouts.len(), + }; + m.layouts.insert( + at.min(m.layouts.len()), + deckcraft_model::Layout { + id, + name, + kind: deckcraft_model::LayoutType::Custom, + shapes: title.into_iter().collect(), + background: None, + show_master_shapes: true, + preserve: true, + color_map: None, + raw_ext: None, + }, + ); + sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; + Ok(json!({"id": id})) + }) +} + +fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let st = s.doc()?; + let (m, l) = match st.selection.target { + Target::Layout { master, layout } => (master, layout), + _ => (master_index(&st.doc, &st.selection), 0), + }; + Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l))) +} + +pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p, "master.renameLayout")?; + let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); + s.edit(|doc, _| { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; + l.name = name; + Ok(()) + })?; + ok() +} + +pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p, "master.deleteLayout")?; + s.edit(|doc, sel| { + let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; + if doc.slides.iter().any(|x| x.layout == id) { + return Err(bad("master.deleteLayout", "slides use this layout")); + } + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); + if m.layouts.len() <= 1 { + return Err(bad("master.deleteLayout", "a master needs at least one layout")); + } + m.layouts.remove(li); + sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; + Ok(()) + })?; + ok() +} + +pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result { + let kind = match str_param(p, "kind").unwrap_or("content") { + "text" => PhType::Body, + "picture" => PhType::Picture, + "chart" => PhType::Chart, + "table" => PhType::Table, + "media" => PhType::Media, + _ => PhType::Obj, + }; + if !matches!(s.doc()?.selection.target, Target::Layout { .. }) { + return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view")); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); + let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; + let shape = Shape { + xfrm: Some(rect), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(TextBody::default()), + ..Default::default() + }; + let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; + Ok(json!({"id": id})) +} + +pub(super) fn preserve(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let (on_master, mi, li, current) = match st.selection.target { + Target::Master { master } => { + let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; + (true, master, 0, on) + } + Target::Layout { master, layout } => { + let on = st + .doc + .masters + .get(master) + .and_then(|m| m.layouts.get(layout)) + .map(|l| l.preserve) + .ok_or_else(|| bad("master.preserve", "no such layout"))?; + (false, master, layout, on) + } + Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")), + }; + let next = bool_param(p, "preserve").unwrap_or(!current); + if next == current { + return Ok(json!({"preserve": next})); + } + s.edit(|doc, _| { + if on_master { + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); + m.preserve = next; + } else { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; + l.preserve = next; + } + Ok(()) + })?; + Ok(json!({"preserve": next})) +} + +pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result { + let title = bool_param(p, "title"); + let footers = bool_param(p, "footers"); + if title.is_none() && footers.is_none() { + return Err(bad("master.placeholders", "give title or footers")); + } + let st = s.doc()?; + if matches!(st.selection.target, Target::Slides) { + return Err(bad("master.placeholders", "open Slide Master view")); + } + let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + let title_changes = title.is_some_and(|want| want != has_title); + let footer_changes = footers.is_some_and(|want| want != has_footers); + if !title_changes && !footer_changes { + return Ok(json!({"title": has_title, "footers": has_footers})); + } + let mut add = Vec::new(); + if title == Some(true) && !has_title { + add.push(PhType::Title); + } + if footers == Some(true) && !has_footers { + add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); + } + let size = st.doc.slide_size; + s.edit(|doc, sel| { + let mut ids = Vec::with_capacity(add.len()); + for _ in 0..add.len() { + ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?)); + } + let prototypes = match sel.target { + Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), + Target::Slides => Vec::new(), + Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")), + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; + if title == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + } + if footers == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); + } + for (kind, id) in add.into_iter().zip(ids) { + let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); + let proto = prototypes.iter().find(|sh| match kind { + PhType::Title => sh.ph_type().is_some_and(PhType::is_title), + other => sh.ph_type() == Some(other), + }); + let shape = match proto { + Some(src) => retarget_placeholder(src.clone(), id, idx), + None => synthesized_placeholder(kind, id, idx, size), + }; + shapes.push(shape); + } + Ok(()) + })?; + let st = s.doc()?; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + Ok(json!({"title": title, "footers": footers})) +} + +fn preferred_ph_idx(kind: PhType) -> u32 { + match kind { + PhType::Title | PhType::CtrTitle => 0, + PhType::Date => 10, + PhType::Footer => 11, + PhType::SlideNum => 12, + _ => 1, + } +} + +fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { + let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); + if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } +} + +/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide +/// number keeps its `‹#›` field. +fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { + sh.id = id; + if let Some(ph) = sh.ph.as_mut() { + ph.idx = idx; + } + let slide_num = sh.ph_type() == Some(PhType::SlideNum); + if slide_num { + let paras = vec![slide_number_paragraph()]; + if let Some(text) = sh.text.as_mut() { + text.paragraphs = paras; + } else { + sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); + } + } else if let Some(text) = sh.text.as_mut() { + text.paragraphs.clear(); + } + sh +} + +fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { + let (name, text) = match kind { + PhType::Date => ("Date Placeholder", TextBody::default()), + PhType::Footer => ("Footer Placeholder", TextBody::default()), + PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), + _ => ("Title", TextBody::default()), + }; + Shape { + id, + name: name.into(), + xfrm: Some(placeholder_rect(kind, size)), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +/// Standard placeholder boxes at 960×540, scaled to the slide size. +fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { + let sx = size.width / 960.0; + let sy = size.height / 540.0; + let (x, y, w, h) = match kind { + PhType::Date => (60.0, 500.0, 216.0, 28.0), + PhType::Footer => (318.0, 500.0, 324.0, 28.0), + PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), + _ => (60.0, 29.0, 840.0, 104.0), + }; + deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) +} + +fn slide_number_paragraph() -> deckcraft_model::Paragraph { + deckcraft_model::Paragraph { + runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], + ..Default::default() + } +} diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs new file mode 100644 index 000000000..39205a343 --- /dev/null +++ b/crates/engine/src/cmd/design/slide_background.rs @@ -0,0 +1,298 @@ +use super::*; + +pub(super) fn background(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "copy", false) { + return copy_slide_background(s); + } + let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); + let transparency = f64_param(p, "transparency"); + let reset = bool_or(p, "reset", false); + let color = color_param(p, "color"); + let style = usize_param(p, "style"); + let op = if reset { + BgOp::Set(None) + } else if let Some(mut c) = color { + if let Some(t) = transparency { + set_solid_transparency(&mut c, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) + } else if let Some(i) = style { + // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). + let slots = + [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; + let i = i.clamp(1, 12) - 1; + BgOp::Set(Some(Background::Ref { + idx: 1001 + (i / 4) as u32, + color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), + })) + } else if let Some(g) = p.get("gradient") { + let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; + g.rotate_with_shape = false; + BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) + } else if let Some(pt) = p.get("pattern") { + BgOp::Set(Some(Background::Fill { + fill: Fill::Pattern(deckcraft_model::style::PatternFill { + preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), + fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), + }), + })) + } else if p.get("picture").is_some() { + let (name, bytes) = + crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; + let ct = crate::cmd::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?; + let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; + if let Some(pp) = &props { + apply_picture_props(&mut pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(&mut pf, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) + } else if props.is_some() || transparency.is_some() { + BgOp::Adjust + } else { + return Err(bad( + "design.background", + "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", + )); + }; + let all = bool_or(p, "all", false); + let index = usize_param(p, "index"); + if matches!(op, BgOp::Adjust) { + let next = { + let st = s.doc()?; + let current = background_of(&st.doc, &st.selection, index); + let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); + let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); + if props.is_some() && !picture { + return Err(bad("design.background", "picture options need a picture background")); + } + if transparency.is_some() && !picture && !solid { + return Err(bad("design.background", "transparency needs a solid or picture background")); + } + let mut next = current.clone(); + if let Some(bg) = next.as_mut() { + adjust_background(bg, props.as_ref(), transparency); + } + if next == current { None } else { Some(next) } + }; + let Some(next) = next else { return ok() }; + s.edit(|doc, sel| { + write_background(doc, sel, index, false, next); + Ok(()) + })?; + return ok(); + } + let BgOp::Set(bg) = op else { return ok() }; + let unchanged = { + let st = s.doc()?; + backgrounds_already(&st.doc, &st.selection, index, all, &bg) + }; + if unchanged { + return ok(); + } + s.edit(|doc, sel| { + write_background(doc, sel, index, all, bg); + Ok(()) + })?; + ok() +} + +enum BgOp { + Set(Option), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + match sel.target { + Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()), + Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), + } +} + +fn used_master_indices(doc: &Presentation) -> BTreeSet { + let fallback = (!doc.masters.is_empty()).then_some(0); + doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect() +} + +fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { + match sel.target { + Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg), + Target::Slides => { + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)); + if all && bg.is_some() { + let masters = used_master_indices(doc); + let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg)); + let layouts_match = !masters.is_empty() + && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none()))); + slides_match && masters_match && layouts_match + } else { + slides_match + } + } + } +} + +fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { + match sel.target { + Target::Master { master } => { + if let Some(m) = doc.masters.get_mut(master) { + Arc::make_mut(m).background = bg; + } + } + Target::Layout { master, layout } => { + if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { + l.background = bg; + } + } + t @ (Target::Handout | Target::NotesMaster) => { + if let Some(m) = crate::page_master_mut(doc, t) { + Arc::make_mut(m).background = bg; + } + } + Target::Slides => { + if all && bg.is_some() { + for idx in used_master_indices(doc) { + if let Some(m) = doc.masters.get_mut(idx) { + let m = Arc::make_mut(m); + m.background = bg.clone(); + for l in &mut m.layouts { + l.background = None; + } + } + } + } + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).background = bg.clone(); + } + } + } + } +} + +fn copy_slide_background(s: &mut Session) -> Result { + let bg = { + let st = s.doc()?; + if !matches!(st.selection.target, Target::Slides) { + return Err(bad("design.background", "copy applies to slides")); + } + let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); + let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg); + let masters_match = if bg.is_some() { + let masters = used_master_indices(&st.doc); + !masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg)) + } else { + true + }; + if slides_match && masters_match { + return ok(); + } + bg + }; + s.edit(|doc, sel| { + write_background(doc, sel, None, true, bg); + Ok(()) + })?; + ok() +} + +fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { + match bg { + Background::Fill { fill: Fill::Picture(pf) } => { + if let Some(pp) = props { + apply_picture_props(pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(pf, t); + } + } + Background::Fill { fill: Fill::Solid { color } } => { + if let Some(t) = transparency { + set_solid_transparency(color, t); + } + } + _ => {} + } +} + +fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); +} + +fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { + color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); + let t = t.clamp(0.0, 1.0); + if t > 0.0 { + color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); + } +} + +/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). +fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { + use deckcraft_model::style::PictureMode; + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + +pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + if let Some(sl) = doc.slides.get_mut(sel.slide) { + let sl = Arc::make_mut(sl); + sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/design/slide_setup.rs b/crates/engine/src/cmd/design/slide_setup.rs new file mode 100644 index 000000000..672a73aab --- /dev/null +++ b/crates/engine/src/cmd/design/slide_setup.rs @@ -0,0 +1,336 @@ +use super::*; + +pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + x.x = x.x * sx + ox; + x.y = x.y * sy + oy; + x.w *= sx; + x.h *= sy; + } + if let Some(t) = sh.text.as_mut() { + deckcraft_model::edit::format_all(t, &|r| { + if let Some(sz) = r.size.as_mut() { + *sz = (*sz * k).max(1.0); + } + }); + } + } +} + +pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result { + let size = if let Some(pre) = str_param(p, "preset") { + let k = pre.to_ascii_lowercase(); + let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k)); + let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?; + Size::new(*w, *h) + } else { + Size::new( + f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0), + f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0), + ) + }; + let size = match str_param(p, "orientation") { + Some("portrait") if size.width > size.height => Size::new(size.height, size.width), + Some("landscape") if size.height > size.width => Size::new(size.height, size.width), + Some("portrait" | "landscape") | None => size, + Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))), + }; + let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); + s.edit(|doc, _| { + let old = doc.slide_size; + let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + let (sx, sy, k, ox, oy) = match mode.as_str() { + "none" => (1.0, 1.0, 1.0, 0.0, 0.0), + "maximize" => { + let k = sx.max(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + _ => { + let k = sx.min(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + }; + // Masters and layouts stretch to the new size; slides scale by mode. + let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + for m in &mut doc.masters { + let m = Arc::make_mut(m); + scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); + for l in &mut m.layouts { + scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); + } + } + for sl in &mut doc.slides { + scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); + } + doc.slide_size = size; + Ok(()) + })?; + Ok(json!({"w": size.width, "h": size.height})) +} + +pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + let hf = &mut doc.header_footer; + if let Some(v) = bool_param(p, "date") { + hf.date = v; + } + if let Some(v) = str_param(p, "dateText") { + hf.date_text = v.into(); + } + if let Some(v) = str_param(p, "dateFormat") { + // Refused rather than ignored: a format that renders nothing would leave the + // placeholder showing its saved text for ever. + if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { + return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); + } + hf.date_format = v.into(); + } + if let Some(v) = bool_param(p, "slideNumber") { + hf.slide_number = v; + } + if let Some(v) = bool_param(p, "footer") { + hf.footer = v; + } + if let Some(v) = str_param(p, "footerText") { + hf.footer_text = v.into(); + hf.footer = hf.footer || !v.is_empty(); + } + if let Some(v) = bool_param(p, "hideOnTitle") { + hf.hide_on_title = v; + } + let hf = hf.clone(); + let date_given = str_param(p, "dateText").is_some(); + let format_given = str_param(p, "dateFormat").is_some(); + let all = bool_or(p, "all", true); + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; + for i in list { + let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; + let lay = doc.layout(layout).map(|(_, l)| l.clone()); + let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title); + let want = |k: PhType| { + !(hf.hide_on_title && is_title) + && match k { + PhType::Date => hf.date, + PhType::SlideNum => hf.slide_number, + PhType::Footer => hf.footer, + _ => false, + } + }; + let mut new_ids = vec![]; + for _ in 0..3 { + new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?)); + } + let Some(sl) = doc.slides.get_mut(i) else { continue }; + let sl = Arc::make_mut(sl); + for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { + let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); + match (has, want(k)) { + (Some(pos), false) => { + sl.shapes.remove(pos); + } + (None, true) => { + let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); + let Some(lp) = lp else { continue }; + let body = match k { + PhType::SlideNum => TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![Run { + text: "‹#›".into(), + props: Default::default(), + kind: RunKind::Field { field: "slidenum".into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date if hf.date_text.is_empty() => TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![Run { + text: date_now(&hf.date_format), + props: Default::default(), + kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date => TextBody::from_text(&hf.date_text), + _ => TextBody::from_text(&hf.footer_text), + }; + sl.shapes.push(Shape { + id: new_ids.get(n).copied().unwrap_or_default(), + name: lp.name.clone(), + ph: lp.ph.clone(), + text: Some(body), + ..Default::default() + }); + } + (Some(pos), true) if k == PhType::Footer => { + if let Some(sh) = sl.shapes.get_mut(pos) { + sh.text = Some(TextBody::from_text(&hf.footer_text)); + } + } + (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { + if let Some(sh) = sl.shapes.get_mut(pos) { + set_date(sh, &hf.date_text, &hf.date_format); + } + } + _ => {} + } + } + } + Ok(()) + })?; + ok() +} + +/// The date field type `format` names, `datetime1` when it is empty or unknown. +fn date_kind(format: &str) -> &str { + if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } +} + +/// The saved text of a date field of `format`, as it shows now. +fn date_now(format: &str) -> String { + let kind = date_kind(format); + deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) +} + +/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty +/// `fixed`, at an automatic date field (one it already has keeps its format). +fn set_date(sh: &mut Shape, fixed: &str, format: &str) { + let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); + let field_now = runs().find_map(|r| match &r.kind { + RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), + _ => None, + }); + let has_field = field_now.is_some(); + let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); + let want = date_kind(format); + let (text, kind) = match fixed { + // Automatic: keep the field, but follow a format that has changed. + "" if field_now.as_deref() == Some(want) => return, + "" if has_field && format.is_empty() => return, + "" => (date_now(format), RunKind::Field { field: want.into() }), + _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, + _ => (fixed.to_string(), RunKind::Text), + }; + let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() }; + sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() }); +} + +/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date +/// field. Empty where there is no clock (wasm). +pub fn today() -> String { + date_field_text("datetime1") +} + +/// What date field `kind` shows now (empty if unknown or there is no clock). +pub fn date_field_text(kind: &str) -> String { + use deckcraft_text::datetime::{DateTime, field_text}; + DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() +} + +/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. +pub fn civil(z: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 + // years (146_097 days). chrono converts the day within its cycle and whole cycles go back + // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and + // 0..146_097 days from it always fits. + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), + } +} + +#[cfg(test)] +mod header_footer_date_tests { + use deckcraft_model::PhType; + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::Session; + + /// The date field of the Date placeholders of every slide: (saved text, field type). + fn dates(s: &Session) -> Vec<(String, Option)> { + let d = &s.doc().unwrap().doc; + d.slides + .iter() + .flat_map(|sl| sl.shapes.iter()) + .filter(|sh| sh.ph_type() == Some(PhType::Date)) + .flat_map(|sh| sh.text.iter()) + .flat_map(|t| t.paragraphs.iter()) + .flat_map(|p| p.runs.iter()) + .map(|r| { + let kind = match &r.kind { + RunKind::Field { field } => Some(field.clone()), + _ => None, + }; + (r.text.clone(), kind) + }) + .collect() + } + + fn sample(kind: &str) -> String { + let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); + deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") + } + + /// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field. + /// The clock is read before and after the command, so a second ticking over between the + /// command and the read cannot fail the comparison. Returns the run text. + fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String { + let before = sample(kind); + s.execute("design.headerFooter", ¶ms).unwrap(); + let after = sample(kind); + let got = dates(s); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].1.as_deref(), Some(kind)); + assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}"); + got[0].0.clone() + } + + /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` + /// existed for this and was never read: the placeholder was always `datetime1`, and a + /// `dateFormat` parameter was accepted and silently dropped. + #[test] + fn the_automatic_date_takes_the_format_it_is_given() { + let mut s = Session::with_new(); + + // Default: the short date, as before. + run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1"); + + // A format: the field type changes and the saved text is that format's. + run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5"); + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); + + // A time format works the same way, and these two differ, so the format is really read. + let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); + assert_ne!(time, sample("datetime5")); + + // Asking again changes nothing (and does not rewrite the run). + let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); + assert_eq!(again, time); + + // A fixed date still wins: plain text, no field. + s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); + + // Back to automatic: the remembered format comes back, not datetime1. + run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11"); + + // An unknown format is refused rather than ignored, and changes nothing. + for bad in ["datetime0", "datetime14", "nonsense", ""] { + assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); + let got = dates(&s); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].1.as_deref(), Some("datetime11")); + } +} From d5180fbb705c5d98556c05b5063eef8c584cdb30 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:55:28 +0000 Subject: [PATCH 49/90] refactor(engine): split cmd/format.rs into cmd/format/{font,text_effects,paragraph,painter,ruler}.rs --- crates/engine/src/cmd/format.rs | 1251 +----------------- crates/engine/src/cmd/format/font.rs | 454 +++++++ crates/engine/src/cmd/format/painter.rs | 145 ++ crates/engine/src/cmd/format/paragraph.rs | 413 ++++++ crates/engine/src/cmd/format/ruler.rs | 116 ++ crates/engine/src/cmd/format/text_effects.rs | 137 ++ 6 files changed, 1285 insertions(+), 1231 deletions(-) create mode 100644 crates/engine/src/cmd/format/font.rs create mode 100644 crates/engine/src/cmd/format/painter.rs create mode 100644 crates/engine/src/cmd/format/paragraph.rs create mode 100644 crates/engine/src/cmd/format/ruler.rs create mode 100644 crates/engine/src/cmd/format/text_effects.rs diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f2955feb7..6e9e75ce8 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -16,6 +16,26 @@ use super::{ use crate::{Result, Session}; use deckcraft_model::ShapeId; +mod font; +mod painter; +mod paragraph; +mod ruler; +mod text_effects; + +pub use font::SIZES; +use font::{ + bold, caps, clear, color, font, font_dialog, grow, highlight, italic, language, language_all, shrink, size, spacing, strike, subscript, + superscript, text_transparency, underline, +}; +pub use painter::Painted; +pub(crate) use painter::apply_painted; +use painter::{font_add, font_rescan, painter_apply, painter_pick, state}; +use paragraph::{ + align, align_to, anchor, autofit, bullets, columns, direction, level, line_spacing, margins, numbering, paragraph, paragraph_dialog, rtl, wrap, +}; +use ruler::{ruler, tabs}; +use text_effects::{case, text_outline, text_shadow, text_warp}; + pub fn specs() -> Vec { vec![ cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add), @@ -287,1237 +307,6 @@ fn current_para(s: &Session) -> ParaProps { } } -fn bold(s: &mut Session, p: &Value) -> Result { - let v = on_param(p, current_run(s).bold); - format_run(s, &move |r| r.bold = Some(v)) -} -fn italic(s: &mut Session, p: &Value) -> Result { - let v = on_param(p, current_run(s).italic); - format_run(s, &move |r| r.italic = Some(v)) -} -fn underline(s: &mut Session, p: &Value) -> Result { - let cur = current_run(s).underline.is_some_and(|u| u != "none"); - let v = on_param(p, Some(cur)); - let style = str_param(p, "style").unwrap_or("sng").to_string(); - format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() })) -} -fn strike(s: &mut Session, p: &Value) -> Result { - let cur = current_run(s).strike.is_some_and(|x| x != Strike::None); - let v = on_param(p, Some(cur)); - let d = bool_or(p, "double", false); - format_run(s, &move |r| { - r.strike = Some(if !v { - Strike::None - } else if d { - Strike::Double - } else { - Strike::Single - }) - }) -} -fn superscript(s: &mut Session, p: &Value) -> Result { - let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0)); - format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 })) -} -fn subscript(s: &mut Session, p: &Value) -> Result { - let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0)); - format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 })) -} -fn font(s: &mut Session, p: &Value) -> Result { - let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string(); - format_run(s, &move |r| r.font = Some(f.clone())) -} -fn size(s: &mut Session, p: &Value) -> Result { - let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0); - format_run(s, &move |r| r.size = Some(v)) -} - -/// PowerPoint's font size list (grow/shrink steps through it). -pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0]; - -fn step(cur: f64, up: bool) -> f64 { - if up { - SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0)) - } else { - SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0)) - } -} - -fn grow_shrink(s: &mut Session, up: bool) -> Result { - // Each run steps from its own size, like PowerPoint. - let st = s.doc()?; - let ctx_sizes = current_run(s).size.unwrap_or(18.0); - let _ = st; - let base = ctx_sizes; - format_run(s, &move |r| { - let cur = r.size.unwrap_or(base); - r.size = Some(step(cur, up)); - }) -} -fn grow(s: &mut Session, _p: &Value) -> Result { - grow_shrink(s, true) -} -fn shrink(s: &mut Session, _p: &Value) -> Result { - grow_shrink(s, false) -} -fn color(s: &mut Session, p: &Value) -> Result { - let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; - // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels), - // which used to answer and change nothing. - let st = s.doc()?; - if st.selection.text.is_none() { - let charts: Vec = - st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect(); - let only_charts = charts.len() == st.selection.shapes.len(); - if !charts.is_empty() { - let tc = c.clone(); - edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| { - if let ShapeKind::Chart(ch) = &mut sh.kind { - ch.text_color = Some(tc.clone()); - ch.raw = None; - } - Ok(()) - })?; - if only_charts { - return ok(); - } - } - } - format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) -} -/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it. -fn with_transparency(c: &mut ColorRef, transparency: f64) { - c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); - if transparency > 0.0 { - c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32)); - } -} - -/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own -/// fill takes the colour it shows now (from the placeholder, master or theme) so only its -/// opacity changes. -fn text_transparency(s: &mut Session, p: &Value) -> Result { - let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?; - if !v.is_finite() { - return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1")); - } - let v = v.clamp(0.0, 1.0); - let shown = match current_run(s).fill { - Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f, - _ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)), - }; - format_run(s, &move |r| { - let mut f = match &r.fill { - Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(), - Some(Fill::None) => return, - _ => shown.clone(), - }; - match &mut f { - Fill::Solid { color } => with_transparency(color, v), - Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)), - _ => {} - } - r.fill = Some(f); - }) -} -/// The `lang` and `noProof` params of the language commands, checked: a run-props setter. -fn language_edit(id: &str, p: &Value) -> Result { - let lang = str_param(p, "lang").map(str::to_string); - let no_proof = bool_param(p, "noProof"); - if lang.is_none() && no_proof.is_none() { - return Err(bad(id, "give `lang` or `noProof`")); - } - if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) { - return Err(bad(id, "`lang` is a language tag such as en-US")); - } - Ok(move |r: &mut RunProps| { - if let Some(l) = &lang { - r.lang = Some(l.clone()); - } - if let Some(n) = no_proof { - r.no_proof = n.then_some(true); - } - }) -} -/// Review › Language on the selected text (or the selected shapes' text). -fn language(s: &mut Session, p: &Value) -> Result { - let f = language_edit("format.language", p)?; - format_run(s, &f) -} -/// Every run in the deck, and the text styles new text inherits from, in one language. -fn language_all(s: &mut Session, p: &Value) -> Result { - let f = language_edit("format.languageAll", p)?; - /// Runs, end marks and the body's own list style levels (never creating one). - fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) { - ed::format_all(t, f); - t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); - } - /// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep). - fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) { - if depth > 64 { - return; - } - for sh in list { - if let Some(t) = &mut sh.text { - body(t, f); - } - match &mut sh.kind { - deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)), - deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1), - _ => {} - } - } - } - /// A master or presentation text style's levels: what new text inherits. - fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) { - // Level 1 always, so a deck without a default text style gets one. - if l.level(0).is_none() { - l.set(0, Default::default()); - } - l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); - } - s.edit(|doc, _| { - for sl in &mut doc.slides { - let sl = Arc::make_mut(sl); - shapes(&mut sl.shapes, &f, 0); - body(&mut sl.notes, &f); - } - let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()]; - for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) { - let m = Arc::make_mut(m); - shapes(&mut m.shapes, &f, 0); - m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0)); - [&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f)); - } - styles(&mut doc.default_text_style, &f); - Ok(()) - })?; - ok() -} -fn highlight(s: &mut Session, p: &Value) -> Result { - let c = color_param(p, "color"); - format_run(s, &move |r| r.highlight = c.clone()) -} -fn caps(s: &mut Session, p: &Value) -> Result { - let c = match str_param(p, "caps").unwrap_or("none") { - "small" => Caps::Small, - "all" => Caps::All, - _ => Caps::None, - }; - format_run(s, &move |r| r.caps = Some(c)) -} -fn spacing(s: &mut Session, p: &Value) -> Result { - let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); - format_run(s, &move |r| r.spacing = Some(v)) -} - -/// Character formatting applied together, so the Font dialog is one undo step. -struct FontEdits { - family: Option, - size: Option, - bold: Option, - italic: Option, - underline: Option, - color: Option, - strike: Option, - baseline: Option, - caps: Option, - spacing: Option, -} - -impl FontEdits { - fn is_empty(&self) -> bool { - self.family.is_none() - && self.size.is_none() - && self.bold.is_none() - && self.italic.is_none() - && self.underline.is_none() - && self.color.is_none() - && self.strike.is_none() - && self.baseline.is_none() - && self.caps.is_none() - && self.spacing.is_none() - } - - fn write(&self, r: &mut RunProps) { - if let Some(f) = &self.family { - r.font = Some(f.clone()); - } - if let Some(v) = self.size { - r.size = Some(v); - } - if let Some(v) = self.bold { - r.bold = Some(v); - } - if let Some(v) = self.italic { - r.italic = Some(v); - } - if let Some(u) = &self.underline { - r.underline = Some(u.clone()); - } - if let Some(c) = &self.color { - r.fill = Some(Fill::solid(c.clone())); - } - if let Some(v) = self.strike { - r.strike = Some(v); - } - if let Some(v) = self.baseline { - r.baseline = Some(v); - } - if let Some(v) = self.caps { - r.caps = Some(v); - } - if let Some(v) = self.spacing { - r.spacing = Some(v); - } - } -} - -fn font_bool(p: &Value, key: &str) -> Result> { - match p.get(key) { - None => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), - } -} - -fn font_str<'a>(p: &'a Value, key: &str) -> Result> { - match p.get(key) { - None => Ok(None), - Some(Value::String(s)) => Ok(Some(s)), - Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), - } -} - -fn font_edits(p: &Value) -> Result { - let family = match font_str(p, "family")? { - None => None, - Some(s) => { - let t = s.trim(); - if t.is_empty() || t.chars().count() > 200 { - return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); - } - Some(t.to_string()) - } - }; - let size = match p.get("size") { - None => None, - Some(v) => { - let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; - Some(n.clamp(1.0, 4000.0)) - } - }; - let underline = match font_str(p, "underline")? { - None => None, - Some(s) => { - if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { - return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); - } - Some(s.to_string()) - } - }; - let color = - if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; - let strike = match font_str(p, "strike")? { - None => None, - Some("none") => Some(Strike::None), - Some("sng" | "single") => Some(Strike::Single), - Some("dbl" | "double") => Some(Strike::Double), - Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), - }; - let baseline = match p.get("baseline") { - None => None, - Some(Value::String(s)) => match s.as_str() { - "none" => Some(0.0), - "super" | "superscript" => Some(0.3), - "sub" | "subscript" => Some(-0.25), - _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), - }, - Some(Value::Number(n)) => { - let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; - Some(f.clamp(-1.0, 1.0)) - } - Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), - }; - let caps = match font_str(p, "caps")? { - None => None, - Some("none") => Some(Caps::None), - Some("small") => Some(Caps::Small), - Some("all") => Some(Caps::All), - Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), - }; - let spacing = match p.get("spacing") { - None => None, - Some(v) => { - let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; - Some(n.clamp(-100.0, 400.0)) - } - }; - Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) -} - -/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. -fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { - use deckcraft_model::ColorBase; - let mut o = serde_json::Map::new(); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().to_string(), json!(v)); - } - } - match &c.base { - ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), - ColorBase::Rgb { rgb } => { - o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); - Value::Object(o) - } - ColorBase::Scheme { slot } => { - o.insert("scheme".into(), json!(slot.xml_name())); - Value::Object(o) - } - ColorBase::Preset { name } => json!(name), - ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), - } -} - -fn font_dialog_params(s: &Session) -> Value { - let r = current_run(s); - let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); - let baseline = r.baseline.unwrap_or(0.0); - let mut v = json!({ - "family": family, - "size": r.size.unwrap_or(18.0), - "bold": r.bold.unwrap_or(false), - "italic": r.italic.unwrap_or(false), - "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), - "strike": match r.strike { - Some(Strike::Double) => "dbl", - Some(Strike::Single) => "sng", - _ => "none", - }, - "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, - "caps": match r.caps { - Some(Caps::Small) => "small", - Some(Caps::All) => "all", - _ => "none", - }, - "spacing": r.spacing.unwrap_or(0.0), - }); - if let Some(Fill::Solid { color }) = &r.fill { - v["color"] = color_command_value(color); - } - v -} - -fn font_dialog(s: &mut Session, p: &Value) -> Result { - let edits = font_edits(p)?; - if edits.is_empty() { - s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); - return ok(); - } - format_run(s, &|r| edits.write(r)) -} -fn clear(s: &mut Session, _p: &Value) -> Result { - format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) -} -fn text_shadow(s: &mut Session, p: &Value) -> Result { - let v = on_param(p, Some(current_run(s).shadow.is_some())); - let sh = deckcraft_model::style::Shadow { - color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)), - blur: 3.0, - dist: 2.0, - dir: 45.0, - inner: false, - sx: 1.0, - sy: 1.0, - kx: 0.0, - ky: 0.0, - align: String::new(), - rotate_with_shape: false, - }; - format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None }) -} -fn text_outline(s: &mut Session, p: &Value) -> Result { - let c = color_param(p, "color"); - let w = f64_or(p, "width", 0.75); - format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w))) -} - -fn text_warp(s: &mut Session, p: &Value) -> Result { - let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?; - let clear = preset == "none" || preset == "textNoShape"; - if !clear && !deckcraft_geom::warp::is_supported(preset) { - return Err(bad("format.textWarp", format!("unknown text transform `{preset}`"))); - } - let adj = match p.get("adj") { - None => Vec::new(), - Some(Value::Array(arr)) => { - if arr.len() > 8 { - return Err(bad("format.textWarp", "at most 8 adjust values")); - } - let mut v = Vec::with_capacity(arr.len()); - for a in arr { - let Some(n) = a.as_f64().filter(|n| n.is_finite()) else { - return Err(bad("format.textWarp", "adjust values must be finite numbers")); - }; - v.push(n.clamp(-2.0e9, 2.0e9)); - } - v - } - Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")), - }; - let stored = if clear { None } else { Some(preset.to_string()) }; - format_body(s, &|b| { - b.warp.clone_from(&stored); - b.warp_adj.clone_from(&adj); - }) -} - -fn case(s: &mut Session, p: &Value) -> Result { - let mode = str_param(p, "mode").unwrap_or("cycle").to_string(); - let st = s.doc()?; - if let Some(t) = st.selection.text.clone() { - let (mut a, mut b) = t.ordered(); - let body = super::text::body_of(st, &t).cloned().unwrap_or_default(); - let collapsed = a == b; - if collapsed { - // Shift+F3 with a bare caret changes the word it is in and puts the caret at its start. - let (wa, wb) = ed::word_at(&body, a); - let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric); - if !(is_word && wa.1 <= a.1 && a.1 < wb.1) { - return ok(); - } - (a, b) = (wa, wb); - } - let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() }; - super::text::edit_text(s, move |body, t| { - let last = body.paragraphs.len().saturating_sub(1); - for pi in a.0..=b.0.min(last) { - let Some(para) = body.paragraphs.get_mut(pi) else { continue }; - let n = para.char_len(); - let from = if pi == a.0 { a.1 } else { 0 }; - let to = if pi == b.0 { b.1 } else { n }; - let mut pos = 0; - let mut pieces = Vec::new(); - for (ri, r) in para.runs.iter().enumerate() { - // Breaks and fields are read for context (a break ends a word) but not rewritten. - let len = r.char_len(); - let rs = from.max(pos).saturating_sub(pos); - let re = to.min(pos + len).saturating_sub(pos); - if re > rs { - pieces.push((ri, rs, re)); - } - pos += len; - } - let mids: Vec = pieces - .iter() - .map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default()) - .collect(); - let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::>(), &m); - for (&(ri, rs, re), new) in pieces.iter().zip(changed) { - let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue }; - let pre: String = r.text.chars().take(rs).collect(); - let post: String = r.text.chars().skip(re).collect(); - r.text = format!("{pre}{new}{post}"); - } - } - if collapsed { - t.anchor = a; - t.caret = a; - } - })?; - return ok(); - } - let mode = if mode == "cycle" { - let all: String = - st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::>().join( - " -", - ); - ed::next_case(&all).to_string() - } else { - mode - }; - edit_shapes(s, &json!({}), "format.case", |sh| { - if let Some(t) = sh.text.as_mut() { - for para in &mut t.paragraphs { - // Breaks and fields are read for context (a break ends a word) but not rewritten. - let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect(); - let changed = ed::change_case_parts(&texts, &mode); - for (r, new) in para.runs.iter_mut().zip(changed) { - if r.kind == deckcraft_model::text::RunKind::Text { - r.text = new; - } - } - } - } - Ok(()) - }) -} - -fn align_to(s: &mut Session, a: Align) -> Result { - format_para(s, &move |p| p.props.align = Some(a)) -} -fn align(s: &mut Session, p: &Value) -> Result { - let a = match str_param(p, "align").unwrap_or("left") { - "center" | "centre" | "ctr" => Align::Center, - "right" | "r" => Align::Right, - "justify" | "just" => Align::Justify, - "justifyLow" | "justLow" => Align::JustLow, - "distributed" | "dist" => Align::Distributed, - _ => Align::Left, - }; - align_to(s, a) -} - -/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat -/// at the start edge stays there. -fn rtl(s: &mut Session, p: &Value) -> Result { - let on = on_param(p, current_para(s).rtl); - format_para(s, &move |para| { - if para.props.rtl.unwrap_or(false) != on { - para.props.align = match para.props.align { - None | Some(Align::Left) => Some(Align::Right), - Some(Align::Right) => Some(Align::Left), - other => other, - }; - } - para.props.rtl = Some(on); - }) -} - -fn bullets(s: &mut Session, p: &Value) -> Result { - let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. })); - let on = on_param(p, Some(cur)); - let ch = str_param(p, "char").unwrap_or("•").to_string(); - let col = color_param(p, "color"); - let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); - let explicit_char = p.get("char").is_some(); - format_para(s, &move |para| { - if on { - para.props.bullet = Some(Bullet::Char { char: ch.clone() }); - para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() }; - if col.is_some() { - para.props.bullet_color = col.clone(); - } - if size.is_some() { - para.props.bullet_size = size; - } - if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) { - para.props.margin_left = Some(18.0 + 36.0 * para.level as f64); - para.props.indent = Some(-18.0); - } - } else { - para.props.bullet = Some(Bullet::None); - para.props.indent = Some(0.0); - para.props.margin_left = Some(36.0 * para.level as f64); - } - }) -} - -fn numbering(s: &mut Session, p: &Value) -> Result { - let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. })); - let on = on_param(p, Some(cur)); - let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string(); - let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1); - let col = color_param(p, "color"); - let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); - format_para(s, &move |para| { - if on { - para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start }); - if col.is_some() { - para.props.bullet_color = col.clone(); - } - if size.is_some() { - para.props.bullet_size = size; - } - para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); - para.props.indent = Some(-27.0); - } else { - para.props.bullet = Some(Bullet::None); - para.props.indent = Some(0.0); - para.props.margin_left = Some(36.0 * para.level as f64); - } - }) -} - -fn level(s: &mut Session, d: i32) -> Result { - format_para(s, &move |p| { - let l = (p.level as i32 + d).clamp(0, 8) as u8; - // Explicit indents shift with the level. - if l != p.level { - if let Some(m) = p.props.margin_left.as_mut() { - *m = (*m + 36.0 * d as f64).max(0.0); - } - p.level = l; - } - }) -} - -fn line_spacing(s: &mut Session, p: &Value) -> Result { - let sp = - if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) }; - format_para(s, &move |para| para.props.line_spacing = Some(sp)) -} - -fn paragraph(s: &mut Session, p: &Value) -> Result { - let a = str_param(p, "align").map(|a| match a { - "center" => Align::Center, - "right" => Align::Right, - "justify" => Align::Justify, - "distributed" => Align::Distributed, - _ => Align::Left, - }); - let ml = f64_param(p, "indentLeft"); - let ind = f64_param(p, "indentFirst"); - let sb = f64_param(p, "spaceBefore"); - let sa = f64_param(p, "spaceAfter"); - let ls = f64_param(p, "lineSpacing"); - let lsp = f64_param(p, "lineSpacingPt"); - format_para(s, &move |para| { - if a.is_some() { - para.props.align = a; - } - if ml.is_some() { - para.props.margin_left = ml.map(|v| v.max(0.0)); - } - if ind.is_some() { - para.props.indent = ind; - } - if let Some(v) = sb { - para.props.space_before = Some(Spacing::Pts(v.max(0.0))); - } - if let Some(v) = sa { - para.props.space_after = Some(Spacing::Pts(v.max(0.0))); - } - if let Some(v) = ls { - para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99))); - } - if let Some(v) = lsp { - para.props.line_spacing = Some(Spacing::Pts(v.max(0.0))); - } - }) -} - -const PARA_DIALOG_KEYS: &[&str] = - &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; - -fn para_dialog_has_fields(p: &Value) -> bool { - PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) -} - -fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { - match p.get(key) { - None => Ok(None), - Some(v) => { - let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; - Ok(Some(n)) - } - } -} - -fn clamp_pt(v: f64) -> f64 { - v.clamp(0.0, 1584.0) -} - -fn parse_align(cmd: &str, s: &str) -> Result { - Ok(match s { - "left" | "l" => Align::Left, - "center" | "ctr" => Align::Center, - "right" | "r" => Align::Right, - "justify" | "just" => Align::Justify, - "justLow" => Align::JustLow, - "distributed" | "dist" => Align::Distributed, - _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), - }) -} - -fn align_name(a: Align) -> &'static str { - match a { - Align::Left => "left", - Align::Center => "center", - Align::Right => "right", - Align::Justify => "justify", - Align::JustLow => "justLow", - Align::Distributed => "distributed", - } -} - -fn spacing_parts(s: Option) -> (&'static str, f64) { - match s { - Some(Spacing::Pct(v)) => ("pct", v), - Some(Spacing::Pts(v)) => ("pts", v), - None => ("pts", 0.0), - } -} - -fn paragraph_dialog_params(s: &Session) -> Value { - let pp = current_para(s); - let indent = pp.indent.unwrap_or(0.0); - let (special, by) = if indent > 0.05 { - ("first", indent) - } else if indent < -0.05 { - ("hanging", -indent) - } else { - ("none", 0.0) - }; - let (line_kind, line_value) = match pp.line_spacing { - Some(Spacing::Pts(v)) => ("exactly", v), - Some(Spacing::Pct(v)) => ("multiple", v), - None => ("multiple", 1.0), - }; - let (before_kind, before) = spacing_parts(pp.space_before); - let (after_kind, after) = spacing_parts(pp.space_after); - let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); - json!({ - "align": align_name(pp.align.unwrap_or(Align::Left)), - "indentLeft": pp.margin_left.unwrap_or(0.0), - "indentRight": pp.margin_right.unwrap_or(0.0), - "special": special, - "specialBy": by, - "spaceBefore": before, - "spaceBeforeKind": before_kind, - "spaceAfter": after, - "spaceAfterKind": after_kind, - "lineKind": line_kind, - "lineValue": line_value, - "tabs": tabs, - "defaultTab": pp.default_tab.unwrap_or(72.0), - }) -} - -fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { - if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } -} - -fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "format.paragraphDialog"; - if !para_dialog_has_fields(p) { - s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); - return ok(); - } - let align = match p.get("align") { - None => None, - Some(Value::String(a)) => Some(parse_align(CMD, a)?), - Some(_) => return Err(bad(CMD, "`align` must be a string")), - }; - let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); - let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); - let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); - let indent = match p.get("special") { - None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), - Some(Value::String(kind)) => { - let by = special_by.unwrap_or(0.0); - Some(match kind.as_str() { - "none" => 0.0, - "first" | "firstLine" => by, - "hanging" | "hang" => -by, - _ => return Err(bad(CMD, "`special` must be none, first or hanging")), - }) - } - Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), - }; - let space_before = finite_key(p, CMD, "spaceBefore")?; - let space_after = finite_key(p, CMD, "spaceAfter")?; - let before_kind = str_param(p, "spaceBeforeKind"); - let after_kind = str_param(p, "spaceAfterKind"); - for kind in [before_kind, after_kind].into_iter().flatten() { - if !matches!(kind, "pts" | "pct") { - return Err(bad(CMD, "spacing kind must be pts or pct")); - } - } - let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); - let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); - format_para(s, &move |para| { - if align.is_some() { - para.props.align = align; - } - if indent_left.is_some() { - para.props.margin_left = indent_left; - } - if indent_right.is_some() { - para.props.margin_right = indent_right; - } - if indent.is_some() { - para.props.indent = indent; - } - if let Some(v) = space_before { - para.props.space_before = Some(write_spacing(before_kind, v)); - } - if let Some(v) = space_after { - para.props.space_after = Some(write_spacing(after_kind, v)); - } - if let Some(v) = line_mult { - para.props.line_spacing = Some(Spacing::Pct(v)); - } - if let Some(v) = line_pt { - para.props.line_spacing = Some(Spacing::Pts(v)); - } - }) -} - -fn tab_align(s: &str) -> Option<&'static str> { - Some(match s { - "l" | "left" => "l", - "ctr" | "center" => "ctr", - "r" | "right" => "r", - "dec" | "decimal" => "dec", - _ => return None, - }) -} - -fn tabs_params(s: &Session) -> Value { - let pp = current_para(s); - let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); - json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) -} - -fn anchor(s: &mut Session, p: &Value) -> Result { - let a = match str_param(p, "anchor").unwrap_or("top") { - "middle" | "center" | "ctr" => Anchor::Middle, - "bottom" | "b" => Anchor::Bottom, - _ => Anchor::Top, - }; - format_body(s, &move |b| b.anchor = Some(a)) -} -fn direction(s: &mut Session, p: &Value) -> Result { - let d = match str_param(p, "dir").unwrap_or("horizontal") { - "rotate90" | "vert" => TextDir::Vertical, - "rotate270" | "vert270" => TextDir::Vertical270, - "stacked" => TextDir::Stacked, - _ => TextDir::Horizontal, - }; - format_body(s, &move |b| b.vert = Some(d)) -} -/// The edited or first selected shape's columns as drawn: the count and the spacing in points. -fn current_columns(s: &Session) -> (u32, f64) { - let Ok(st) = s.doc() else { return (1, 0.0) }; - let id = match &st.selection.text { - Some(t) if !t.notes => Some(t.shape), - _ => st.selection.shapes.first().copied(), - }; - let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) }; - let body = match super::text::ctx_for(&st.doc, &st.selection) { - Some(c) => resolve::body(&c, sh), - None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), - }; - (body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0)) -} -fn columns(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "format.columns"; - let count = match p.get("count") { - None => None, - Some(v) => { - let n = v - .as_f64() - .filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n)) - .ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?; - Some(n as u32) - } - }; - let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt); - if count.is_none() && spacing.is_none() { - let (count, spacing) = current_columns(s); - s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) }); - return ok(); - } - format_body(s, &move |b| { - if let Some(n) = count { - b.columns = Some(n); - } - if let Some(sp) = spacing { - b.col_spacing = Some(sp); - } - }) -} -fn autofit(s: &mut Session, p: &Value) -> Result { - let m = match str_param(p, "mode").unwrap_or("none") { - "shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }, - "resize" | "shape" => AutoFit::Shape, - _ => AutoFit::None, - }; - format_body(s, &move |b| b.autofit = Some(m)) -} -fn wrap(s: &mut Session, p: &Value) -> Result { - let on = bool_or(p, "on", true); - format_body(s, &move |b| b.wrap = Some(on)) -} -fn margins(s: &mut Session, p: &Value) -> Result { - let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")); - format_body(s, &move |bp| { - if l.is_some() { - bp.inset_l = l.map(|v| v.max(0.0)); - } - if t.is_some() { - bp.inset_t = t.map(|v| v.max(0.0)); - } - if r.is_some() { - bp.inset_r = r.map(|v| v.max(0.0)); - } - if b.is_some() { - bp.inset_b = b.map(|v| v.max(0.0)); - } - }) -} - -/// Formatting picked up by the Format Painter. -#[derive(Clone, Debug, Default)] -pub struct Painted { - pub run: Option, - pub para: Option, - pub fill: Option, - pub line: Option, - pub effects: Option, - pub style: Option, -} - -fn painter_pick(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let mut painted = Painted::default(); - if let Some(t) = &st.selection.text { - if let Some(b) = super::text::body_of(st, t) { - let a = t.ordered().0; - painted.run = Some(ed::props_at(b, (a.0, a.1 + 1))); - painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone()); - } - } else if let Some(sh) = st.selected_shapes().first() { - painted.fill = sh.fill.clone(); - painted.line = sh.line.clone(); - painted.effects = sh.effects.clone(); - painted.style = sh.style.clone(); - painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()); - } - s.painter = Some((painted, bool_or(p, "sticky", false))); - Ok(json!({"armed": true})) -} - -pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) { - if p.fill.is_some() || p.style.is_some() { - sh.fill.clone_from(&p.fill); - sh.line.clone_from(&p.line); - sh.effects.clone_from(&p.effects); - sh.style.clone_from(&p.style); - } - if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) { - ed::format_all(t, &|rp| *rp = r.clone()); - } -} - -/// The paragraphs the text selection covers whole: it starts at or before their start and -/// reaches their end. A paragraph the selection only touches the start of (it ends at offset -/// 0, as after a triple-click) is not covered. -fn covered_paragraphs(s: &Session) -> Vec { - let Ok(st) = s.doc() else { return vec![] }; - let Some(t) = &st.selection.text else { return vec![] }; - let Some(body) = super::text::body_of(st, t) else { return vec![] }; - let (a, b) = t.ordered(); - // A hostile selection can point past the body: walk only the paragraphs it has. - (a.0..=b.0.min(body.paragraphs.len().saturating_sub(1))) - .filter(|&pi| { - let Some(para) = body.paragraphs.get(pi) else { return false }; - let starts = pi > a.0 || a.1 == 0; - let ends = if pi < b.0 { - true - } else if b.0 > a.0 && b.1 == 0 { - false - } else { - b.1 >= para.char_len() - }; - starts && ends - }) - .collect() -} - -fn painter_apply(s: &mut Session, p: &Value) -> Result { - let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) }; - if !sticky { - s.painter = None; - } - if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() { - if let Some(r) = painted.run.clone() { - format_run(s, &move |rp| *rp = r.clone())?; - } - // Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the - // selection covers whole; painting part of a bulleted line changes just its text. - let covered = covered_paragraphs(s); - if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) { - super::text::edit_text(s, |body, _| { - for &pi in &covered { - if let Some(para) = body.paragraphs.get_mut(pi) { - para.props = pp.clone(); - } - } - })?; - } - return ok(); - } - edit_shapes(s, p, "format.painterApply", |sh| { - apply_painted(sh, &painted); - Ok(()) - }) -} - -fn state(s: &mut Session, _p: &Value) -> Result { - let r = current_run(s); - let pp = current_para(s); - let st = s.doc()?; - let family = super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default(); - Ok(json!({ - "font": family, - "fontRef": r.font, - "size": r.size, - "bold": r.bold.unwrap_or(false), - "italic": r.italic.unwrap_or(false), - "underline": r.underline.as_deref().is_some_and(|u| u != "none"), - "strike": r.strike.is_some_and(|x| x != Strike::None), - "superscript": r.baseline.unwrap_or(0.0) > 0.0, - "subscript": r.baseline.unwrap_or(0.0) < 0.0, - "align": pp.align.map(|a| a.xml()), - "bullets": matches!(pp.bullet, Some(Bullet::Char { .. })), - "numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })), - "lang": r.lang, - "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), - "painter": s.painter.is_some(), - })) -} - -/// Add a font from its bytes for this session (it then shows in the font menu and draws). -fn font_add(_s: &mut Session, p: &Value) -> Result { - let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?; - if data.len() > 64 << 20 { - return Err(bad("font.add", "font file larger than 64 MB")); - } - let faces = deckcraft_fonts::FontDb::global().add_font(data); - if faces == 0 { - return Err(bad("font.add", "not a font DeckCraft can read (or already added)")); - } - Ok(json!({"faces": faces})) -} - -/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear. -fn font_rescan(_s: &mut Session, _p: &Value) -> Result { - let db = deckcraft_fonts::FontDb::global(); - #[cfg(not(target_arch = "wasm32"))] - let faces = db.load_system_fonts(); - #[cfg(target_arch = "wasm32")] - let faces = 0usize; - Ok(json!({"faces": faces, "families": db.families().len()})) -} - -/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, -/// and where its text area sits on the slide. -fn ruler(s: &mut Session, _p: &Value) -> Result { - let props = current_para(s); - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; - let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { - (false, Some(sh)) => { - let ctx = super::text::ctx_for(&st.doc, &st.selection); - let x = match &ctx { - Some(c) => resolve::xfrm(c, sh), - None => sh.xfrm.unwrap_or_default(), - }; - let body = match &ctx { - Some(c) => resolve::body(c, sh), - None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), - }; - let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); - (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) - } - _ => (0.0, 0.0, 0.0, true), - }; - let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); - Ok(json!({ - "x": x, "y": y, "width": w, "rotated": rotated, - "marginLeft": props.margin_left.unwrap_or(0.0), - "indent": props.indent.unwrap_or(0.0), - "tabs": tabs, - "defaultTab": props.default_tab.unwrap_or(72.0), - })) -} - -const MAX_TABS: usize = 32; - -fn tabs(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "format.tabs"; - let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); - let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { - "ctr" | "center" => "ctr", - "r" | "right" => "r", - "dec" | "decimal" => "dec", - _ => "l", - }; - let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); - let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); - let remove = fin(p.get("remove").and_then(Value::as_f64)); - // `set` replaces every stop: entries without a usable position are dropped, an unknown - // alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins). - let set = match p.get("set") { - None => None, - Some(Value::Array(items)) => { - if items.len() > 64 { - return Err(bad(CMD, "`set` has more than 64 tab stops")); - } - let mut out: Vec = Vec::new(); - for (i, item) in items.iter().enumerate() { - let Some(obj) = item.as_object() else { - return Err(bad(CMD, format!("tab stop {i} must be an object"))); - }; - let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else { - continue; - }; - let align = match obj.get("align").and_then(Value::as_str) { - None => "l", - Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, - }; - if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { - *prev = TabStop { pos, align: align.to_string() }; - } else { - out.push(TabStop { pos, align: align.to_string() }); - } - } - Some(out) - } - Some(_) => return Err(bad(CMD, "`set` must be an array")), - }; - let clear = bool_or(p, "clear", false); - let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); - let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear; - if !edits_stops && default_tab.is_none() { - // No fields: open the Tabs dialog on the caret's paragraph. - s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); - return ok(); - } - // The stops as the caret's paragraph shows them (inherited ones included), so editing a - // placeholder's tab that came from its layout works like editing one set on the paragraph. - let base = current_para(s).tabs.unwrap_or_default(); - let near = |a: f64, b: f64| (a - b).abs() < 0.5; - let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; - if let Some(at) = remove { - stops.retain(|t| !near(t.pos, at)); - } - if let Some((from, to)) = mv - && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) - { - t.pos = to; - } - if let Some((pos, align)) = add { - stops.retain(|t| !near(t.pos, pos)); - stops.push(TabStop { pos, align }); - } - stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); - stops.dedup_by(|a, b| near(a.pos, b.pos)); - stops.truncate(MAX_TABS); - format_para(s, &move |para| { - if edits_stops { - para.props.tabs = Some(stops.clone()); - } - if let Some(d) = default_tab { - para.props.default_tab = Some(d); - } - }) -} - #[cfg(test)] #[path = "ruler_tests.rs"] mod ruler_tests; diff --git a/crates/engine/src/cmd/format/font.rs b/crates/engine/src/cmd/format/font.rs new file mode 100644 index 000000000..e0b4a5b4d --- /dev/null +++ b/crates/engine/src/cmd/format/font.rs @@ -0,0 +1,454 @@ +use super::*; + +pub(super) fn bold(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, current_run(s).bold); + format_run(s, &move |r| r.bold = Some(v)) +} + +pub(super) fn italic(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, current_run(s).italic); + format_run(s, &move |r| r.italic = Some(v)) +} + +pub(super) fn underline(s: &mut Session, p: &Value) -> Result { + let cur = current_run(s).underline.is_some_and(|u| u != "none"); + let v = on_param(p, Some(cur)); + let style = str_param(p, "style").unwrap_or("sng").to_string(); + format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() })) +} + +pub(super) fn strike(s: &mut Session, p: &Value) -> Result { + let cur = current_run(s).strike.is_some_and(|x| x != Strike::None); + let v = on_param(p, Some(cur)); + let d = bool_or(p, "double", false); + format_run(s, &move |r| { + r.strike = Some(if !v { + Strike::None + } else if d { + Strike::Double + } else { + Strike::Single + }) + }) +} + +pub(super) fn superscript(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0)); + format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 })) +} + +pub(super) fn subscript(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0)); + format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 })) +} + +pub(super) fn font(s: &mut Session, p: &Value) -> Result { + let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string(); + format_run(s, &move |r| r.font = Some(f.clone())) +} + +pub(super) fn size(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0); + format_run(s, &move |r| r.size = Some(v)) +} + +/// PowerPoint's font size list (grow/shrink steps through it). +pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0]; + +fn step(cur: f64, up: bool) -> f64 { + if up { + SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0)) + } else { + SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0)) + } +} + +fn grow_shrink(s: &mut Session, up: bool) -> Result { + // Each run steps from its own size, like PowerPoint. + let st = s.doc()?; + let ctx_sizes = current_run(s).size.unwrap_or(18.0); + let _ = st; + let base = ctx_sizes; + format_run(s, &move |r| { + let cur = r.size.unwrap_or(base); + r.size = Some(step(cur, up)); + }) +} + +pub(super) fn grow(s: &mut Session, _p: &Value) -> Result { + grow_shrink(s, true) +} + +pub(super) fn shrink(s: &mut Session, _p: &Value) -> Result { + grow_shrink(s, false) +} + +pub(super) fn color(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; + // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels), + // which used to answer and change nothing. + let st = s.doc()?; + if st.selection.text.is_none() { + let charts: Vec = + st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect(); + let only_charts = charts.len() == st.selection.shapes.len(); + if !charts.is_empty() { + let tc = c.clone(); + edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.text_color = Some(tc.clone()); + ch.raw = None; + } + Ok(()) + })?; + if only_charts { + return ok(); + } + } + } + format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) +} + +/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it. +fn with_transparency(c: &mut ColorRef, transparency: f64) { + c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if transparency > 0.0 { + c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32)); + } +} + +/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own +/// fill takes the colour it shows now (from the placeholder, master or theme) so only its +/// opacity changes. +pub(super) fn text_transparency(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?; + if !v.is_finite() { + return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1")); + } + let v = v.clamp(0.0, 1.0); + let shown = match current_run(s).fill { + Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f, + _ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)), + }; + format_run(s, &move |r| { + let mut f = match &r.fill { + Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(), + Some(Fill::None) => return, + _ => shown.clone(), + }; + match &mut f { + Fill::Solid { color } => with_transparency(color, v), + Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)), + _ => {} + } + r.fill = Some(f); + }) +} + +/// The `lang` and `noProof` params of the language commands, checked: a run-props setter. +fn language_edit(id: &str, p: &Value) -> Result { + let lang = str_param(p, "lang").map(str::to_string); + let no_proof = bool_param(p, "noProof"); + if lang.is_none() && no_proof.is_none() { + return Err(bad(id, "give `lang` or `noProof`")); + } + if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) { + return Err(bad(id, "`lang` is a language tag such as en-US")); + } + Ok(move |r: &mut RunProps| { + if let Some(l) = &lang { + r.lang = Some(l.clone()); + } + if let Some(n) = no_proof { + r.no_proof = n.then_some(true); + } + }) +} + +/// Review › Language on the selected text (or the selected shapes' text). +pub(super) fn language(s: &mut Session, p: &Value) -> Result { + let f = language_edit("format.language", p)?; + format_run(s, &f) +} + +/// Every run in the deck, and the text styles new text inherits from, in one language. +pub(super) fn language_all(s: &mut Session, p: &Value) -> Result { + let f = language_edit("format.languageAll", p)?; + /// Runs, end marks and the body's own list style levels (never creating one). + fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) { + ed::format_all(t, f); + t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); + } + /// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep). + fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) { + if depth > 64 { + return; + } + for sh in list { + if let Some(t) = &mut sh.text { + body(t, f); + } + match &mut sh.kind { + deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)), + deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1), + _ => {} + } + } + } + /// A master or presentation text style's levels: what new text inherits. + fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) { + // Level 1 always, so a deck without a default text style gets one. + if l.level(0).is_none() { + l.set(0, Default::default()); + } + l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); + } + s.edit(|doc, _| { + for sl in &mut doc.slides { + let sl = Arc::make_mut(sl); + shapes(&mut sl.shapes, &f, 0); + body(&mut sl.notes, &f); + } + let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()]; + for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) { + let m = Arc::make_mut(m); + shapes(&mut m.shapes, &f, 0); + m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0)); + [&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f)); + } + styles(&mut doc.default_text_style, &f); + Ok(()) + })?; + ok() +} + +pub(super) fn highlight(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color"); + format_run(s, &move |r| r.highlight = c.clone()) +} + +pub(super) fn caps(s: &mut Session, p: &Value) -> Result { + let c = match str_param(p, "caps").unwrap_or("none") { + "small" => Caps::Small, + "all" => Caps::All, + _ => Caps::None, + }; + format_run(s, &move |r| r.caps = Some(c)) +} + +pub(super) fn spacing(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); + format_run(s, &move |r| r.spacing = Some(v)) +} + +/// Character formatting applied together, so the Font dialog is one undo step. +struct FontEdits { + family: Option, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +impl FontEdits { + fn is_empty(&self) -> bool { + self.family.is_none() + && self.size.is_none() + && self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.color.is_none() + && self.strike.is_none() + && self.baseline.is_none() + && self.caps.is_none() + && self.spacing.is_none() + } + + fn write(&self, r: &mut RunProps) { + if let Some(f) = &self.family { + r.font = Some(f.clone()); + } + if let Some(v) = self.size { + r.size = Some(v); + } + if let Some(v) = self.bold { + r.bold = Some(v); + } + if let Some(v) = self.italic { + r.italic = Some(v); + } + if let Some(u) = &self.underline { + r.underline = Some(u.clone()); + } + if let Some(c) = &self.color { + r.fill = Some(Fill::solid(c.clone())); + } + if let Some(v) = self.strike { + r.strike = Some(v); + } + if let Some(v) = self.baseline { + r.baseline = Some(v); + } + if let Some(v) = self.caps { + r.caps = Some(v); + } + if let Some(v) = self.spacing { + r.spacing = Some(v); + } + } +} + +fn font_bool(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), + } +} + +fn font_str<'a>(p: &'a Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), + } +} + +fn font_edits(p: &Value) -> Result { + let family = match font_str(p, "family")? { + None => None, + Some(s) => { + let t = s.trim(); + if t.is_empty() || t.chars().count() > 200 { + return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); + } + Some(t.to_string()) + } + }; + let size = match p.get("size") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; + Some(n.clamp(1.0, 4000.0)) + } + }; + let underline = match font_str(p, "underline")? { + None => None, + Some(s) => { + if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { + return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); + } + Some(s.to_string()) + } + }; + let color = + if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; + let strike = match font_str(p, "strike")? { + None => None, + Some("none") => Some(Strike::None), + Some("sng" | "single") => Some(Strike::Single), + Some("dbl" | "double") => Some(Strike::Double), + Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), + }; + let baseline = match p.get("baseline") { + None => None, + Some(Value::String(s)) => match s.as_str() { + "none" => Some(0.0), + "super" | "superscript" => Some(0.3), + "sub" | "subscript" => Some(-0.25), + _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }, + Some(Value::Number(n)) => { + let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; + Some(f.clamp(-1.0, 1.0)) + } + Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }; + let caps = match font_str(p, "caps")? { + None => None, + Some("none") => Some(Caps::None), + Some("small") => Some(Caps::Small), + Some("all") => Some(Caps::All), + Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), + }; + let spacing = match p.get("spacing") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; + Some(n.clamp(-100.0, 400.0)) + } + }; + Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) +} + +/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. +fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { + use deckcraft_model::ColorBase; + let mut o = serde_json::Map::new(); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().to_string(), json!(v)); + } + } + match &c.base { + ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), + ColorBase::Rgb { rgb } => { + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + Value::Object(o) + } + ColorBase::Scheme { slot } => { + o.insert("scheme".into(), json!(slot.xml_name())); + Value::Object(o) + } + ColorBase::Preset { name } => json!(name), + ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), + } +} + +fn font_dialog_params(s: &Session) -> Value { + let r = current_run(s); + let family = + s.doc().ok().and_then(|st| crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); + let baseline = r.baseline.unwrap_or(0.0); + let mut v = json!({ + "family": family, + "size": r.size.unwrap_or(18.0), + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), + "strike": match r.strike { + Some(Strike::Double) => "dbl", + Some(Strike::Single) => "sng", + _ => "none", + }, + "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, + "caps": match r.caps { + Some(Caps::Small) => "small", + Some(Caps::All) => "all", + _ => "none", + }, + "spacing": r.spacing.unwrap_or(0.0), + }); + if let Some(Fill::Solid { color }) = &r.fill { + v["color"] = color_command_value(color); + } + v +} + +pub(super) fn font_dialog(s: &mut Session, p: &Value) -> Result { + let edits = font_edits(p)?; + if edits.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); + return ok(); + } + format_run(s, &|r| edits.write(r)) +} + +pub(super) fn clear(s: &mut Session, _p: &Value) -> Result { + format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) +} diff --git a/crates/engine/src/cmd/format/painter.rs b/crates/engine/src/cmd/format/painter.rs new file mode 100644 index 000000000..51e310288 --- /dev/null +++ b/crates/engine/src/cmd/format/painter.rs @@ -0,0 +1,145 @@ +use super::*; + +/// Formatting picked up by the Format Painter. +#[derive(Clone, Debug, Default)] +pub struct Painted { + pub run: Option, + pub para: Option, + pub fill: Option, + pub line: Option, + pub effects: Option, + pub style: Option, +} + +pub(super) fn painter_pick(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let mut painted = Painted::default(); + if let Some(t) = &st.selection.text { + if let Some(b) = crate::cmd::text::body_of(st, t) { + let a = t.ordered().0; + painted.run = Some(ed::props_at(b, (a.0, a.1 + 1))); + painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone()); + } + } else if let Some(sh) = st.selected_shapes().first() { + painted.fill = sh.fill.clone(); + painted.line = sh.line.clone(); + painted.effects = sh.effects.clone(); + painted.style = sh.style.clone(); + painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()); + } + s.painter = Some((painted, bool_or(p, "sticky", false))); + Ok(json!({"armed": true})) +} + +pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) { + if p.fill.is_some() || p.style.is_some() { + sh.fill.clone_from(&p.fill); + sh.line.clone_from(&p.line); + sh.effects.clone_from(&p.effects); + sh.style.clone_from(&p.style); + } + if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) { + ed::format_all(t, &|rp| *rp = r.clone()); + } +} + +/// The paragraphs the text selection covers whole: it starts at or before their start and +/// reaches their end. A paragraph the selection only touches the start of (it ends at offset +/// 0, as after a triple-click) is not covered. +fn covered_paragraphs(s: &Session) -> Vec { + let Ok(st) = s.doc() else { return vec![] }; + let Some(t) = &st.selection.text else { return vec![] }; + let Some(body) = crate::cmd::text::body_of(st, t) else { return vec![] }; + let (a, b) = t.ordered(); + // A hostile selection can point past the body: walk only the paragraphs it has. + (a.0..=b.0.min(body.paragraphs.len().saturating_sub(1))) + .filter(|&pi| { + let Some(para) = body.paragraphs.get(pi) else { return false }; + let starts = pi > a.0 || a.1 == 0; + let ends = if pi < b.0 { + true + } else if b.0 > a.0 && b.1 == 0 { + false + } else { + b.1 >= para.char_len() + }; + starts && ends + }) + .collect() +} + +pub(super) fn painter_apply(s: &mut Session, p: &Value) -> Result { + let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) }; + if !sticky { + s.painter = None; + } + if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() { + if let Some(r) = painted.run.clone() { + format_run(s, &move |rp| *rp = r.clone())?; + } + // Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the + // selection covers whole; painting part of a bulleted line changes just its text. + let covered = covered_paragraphs(s); + if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) { + crate::cmd::text::edit_text(s, |body, _| { + for &pi in &covered { + if let Some(para) = body.paragraphs.get_mut(pi) { + para.props = pp.clone(); + } + } + })?; + } + return ok(); + } + edit_shapes(s, p, "format.painterApply", |sh| { + apply_painted(sh, &painted); + Ok(()) + }) +} + +pub(super) fn state(s: &mut Session, _p: &Value) -> Result { + let r = current_run(s); + let pp = current_para(s); + let st = s.doc()?; + let family = crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default(); + Ok(json!({ + "font": family, + "fontRef": r.font, + "size": r.size, + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().is_some_and(|u| u != "none"), + "strike": r.strike.is_some_and(|x| x != Strike::None), + "superscript": r.baseline.unwrap_or(0.0) > 0.0, + "subscript": r.baseline.unwrap_or(0.0) < 0.0, + "align": pp.align.map(|a| a.xml()), + "bullets": matches!(pp.bullet, Some(Bullet::Char { .. })), + "numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })), + "lang": r.lang, + "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), + "painter": s.painter.is_some(), + })) +} + +/// Add a font from its bytes for this session (it then shows in the font menu and draws). +pub(super) fn font_add(_s: &mut Session, p: &Value) -> Result { + let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?; + if data.len() > 64 << 20 { + return Err(bad("font.add", "font file larger than 64 MB")); + } + let faces = deckcraft_fonts::FontDb::global().add_font(data); + if faces == 0 { + return Err(bad("font.add", "not a font DeckCraft can read (or already added)")); + } + Ok(json!({"faces": faces})) +} + +/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear. +pub(super) fn font_rescan(_s: &mut Session, _p: &Value) -> Result { + let db = deckcraft_fonts::FontDb::global(); + #[cfg(not(target_arch = "wasm32"))] + let faces = db.load_system_fonts(); + #[cfg(target_arch = "wasm32")] + let faces = 0usize; + Ok(json!({"faces": faces, "families": db.families().len()})) +} diff --git a/crates/engine/src/cmd/format/paragraph.rs b/crates/engine/src/cmd/format/paragraph.rs new file mode 100644 index 000000000..244433ae2 --- /dev/null +++ b/crates/engine/src/cmd/format/paragraph.rs @@ -0,0 +1,413 @@ +use super::*; + +pub(super) fn align_to(s: &mut Session, a: Align) -> Result { + format_para(s, &move |p| p.props.align = Some(a)) +} + +pub(super) fn align(s: &mut Session, p: &Value) -> Result { + let a = match str_param(p, "align").unwrap_or("left") { + "center" | "centre" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justifyLow" | "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => Align::Left, + }; + align_to(s, a) +} + +/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat +/// at the start edge stays there. +pub(super) fn rtl(s: &mut Session, p: &Value) -> Result { + let on = on_param(p, current_para(s).rtl); + format_para(s, &move |para| { + if para.props.rtl.unwrap_or(false) != on { + para.props.align = match para.props.align { + None | Some(Align::Left) => Some(Align::Right), + Some(Align::Right) => Some(Align::Left), + other => other, + }; + } + para.props.rtl = Some(on); + }) +} + +pub(super) fn bullets(s: &mut Session, p: &Value) -> Result { + let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. })); + let on = on_param(p, Some(cur)); + let ch = str_param(p, "char").unwrap_or("•").to_string(); + let col = color_param(p, "color"); + let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); + let explicit_char = p.get("char").is_some(); + format_para(s, &move |para| { + if on { + para.props.bullet = Some(Bullet::Char { char: ch.clone() }); + para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() }; + if col.is_some() { + para.props.bullet_color = col.clone(); + } + if size.is_some() { + para.props.bullet_size = size; + } + if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) { + para.props.margin_left = Some(18.0 + 36.0 * para.level as f64); + para.props.indent = Some(-18.0); + } + } else { + para.props.bullet = Some(Bullet::None); + para.props.indent = Some(0.0); + para.props.margin_left = Some(36.0 * para.level as f64); + } + }) +} + +pub(super) fn numbering(s: &mut Session, p: &Value) -> Result { + let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. })); + let on = on_param(p, Some(cur)); + let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string(); + let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1); + let col = color_param(p, "color"); + let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); + format_para(s, &move |para| { + if on { + para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start }); + if col.is_some() { + para.props.bullet_color = col.clone(); + } + if size.is_some() { + para.props.bullet_size = size; + } + para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); + para.props.indent = Some(-27.0); + } else { + para.props.bullet = Some(Bullet::None); + para.props.indent = Some(0.0); + para.props.margin_left = Some(36.0 * para.level as f64); + } + }) +} + +pub(super) fn level(s: &mut Session, d: i32) -> Result { + format_para(s, &move |p| { + let l = (p.level as i32 + d).clamp(0, 8) as u8; + // Explicit indents shift with the level. + if l != p.level { + if let Some(m) = p.props.margin_left.as_mut() { + *m = (*m + 36.0 * d as f64).max(0.0); + } + p.level = l; + } + }) +} + +pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result { + let sp = + if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) }; + format_para(s, &move |para| para.props.line_spacing = Some(sp)) +} + +pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result { + let a = str_param(p, "align").map(|a| match a { + "center" => Align::Center, + "right" => Align::Right, + "justify" => Align::Justify, + "distributed" => Align::Distributed, + _ => Align::Left, + }); + let ml = f64_param(p, "indentLeft"); + let ind = f64_param(p, "indentFirst"); + let sb = f64_param(p, "spaceBefore"); + let sa = f64_param(p, "spaceAfter"); + let ls = f64_param(p, "lineSpacing"); + let lsp = f64_param(p, "lineSpacingPt"); + format_para(s, &move |para| { + if a.is_some() { + para.props.align = a; + } + if ml.is_some() { + para.props.margin_left = ml.map(|v| v.max(0.0)); + } + if ind.is_some() { + para.props.indent = ind; + } + if let Some(v) = sb { + para.props.space_before = Some(Spacing::Pts(v.max(0.0))); + } + if let Some(v) = sa { + para.props.space_after = Some(Spacing::Pts(v.max(0.0))); + } + if let Some(v) = ls { + para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99))); + } + if let Some(v) = lsp { + para.props.line_spacing = Some(Spacing::Pts(v.max(0.0))); + } + }) +} + +const PARA_DIALOG_KEYS: &[&str] = + &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; + +fn para_dialog_has_fields(p: &Value) -> bool { + PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) +} + +pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; + Ok(Some(n)) + } + } +} + +pub(super) fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + Ok(match s { + "left" | "l" => Align::Left, + "center" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + }) +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::JustLow => "justLow", + Align::Distributed => "distributed", + } +} + +fn spacing_parts(s: Option) -> (&'static str, f64) { + match s { + Some(Spacing::Pct(v)) => ("pct", v), + Some(Spacing::Pts(v)) => ("pts", v), + None => ("pts", 0.0), + } +} + +fn paragraph_dialog_params(s: &Session) -> Value { + let pp = current_para(s); + let indent = pp.indent.unwrap_or(0.0); + let (special, by) = if indent > 0.05 { + ("first", indent) + } else if indent < -0.05 { + ("hanging", -indent) + } else { + ("none", 0.0) + }; + let (line_kind, line_value) = match pp.line_spacing { + Some(Spacing::Pts(v)) => ("exactly", v), + Some(Spacing::Pct(v)) => ("multiple", v), + None => ("multiple", 1.0), + }; + let (before_kind, before) = spacing_parts(pp.space_before); + let (after_kind, after) = spacing_parts(pp.space_after); + let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({ + "align": align_name(pp.align.unwrap_or(Align::Left)), + "indentLeft": pp.margin_left.unwrap_or(0.0), + "indentRight": pp.margin_right.unwrap_or(0.0), + "special": special, + "specialBy": by, + "spaceBefore": before, + "spaceBeforeKind": before_kind, + "spaceAfter": after, + "spaceAfterKind": after_kind, + "lineKind": line_kind, + "lineValue": line_value, + "tabs": tabs, + "defaultTab": pp.default_tab.unwrap_or(72.0), + }) +} + +fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { + if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } +} + +pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.paragraphDialog"; + if !para_dialog_has_fields(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); + return ok(); + } + let align = match p.get("align") { + None => None, + Some(Value::String(a)) => Some(parse_align(CMD, a)?), + Some(_) => return Err(bad(CMD, "`align` must be a string")), + }; + let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); + let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); + let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); + let indent = match p.get("special") { + None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), + Some(Value::String(kind)) => { + let by = special_by.unwrap_or(0.0); + Some(match kind.as_str() { + "none" => 0.0, + "first" | "firstLine" => by, + "hanging" | "hang" => -by, + _ => return Err(bad(CMD, "`special` must be none, first or hanging")), + }) + } + Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), + }; + let space_before = finite_key(p, CMD, "spaceBefore")?; + let space_after = finite_key(p, CMD, "spaceAfter")?; + let before_kind = str_param(p, "spaceBeforeKind"); + let after_kind = str_param(p, "spaceAfterKind"); + for kind in [before_kind, after_kind].into_iter().flatten() { + if !matches!(kind, "pts" | "pct") { + return Err(bad(CMD, "spacing kind must be pts or pct")); + } + } + let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); + let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + format_para(s, &move |para| { + if align.is_some() { + para.props.align = align; + } + if indent_left.is_some() { + para.props.margin_left = indent_left; + } + if indent_right.is_some() { + para.props.margin_right = indent_right; + } + if indent.is_some() { + para.props.indent = indent; + } + if let Some(v) = space_before { + para.props.space_before = Some(write_spacing(before_kind, v)); + } + if let Some(v) = space_after { + para.props.space_after = Some(write_spacing(after_kind, v)); + } + if let Some(v) = line_mult { + para.props.line_spacing = Some(Spacing::Pct(v)); + } + if let Some(v) = line_pt { + para.props.line_spacing = Some(Spacing::Pts(v)); + } + }) +} + +pub(super) fn tab_align(s: &str) -> Option<&'static str> { + Some(match s { + "l" | "left" => "l", + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => return None, + }) +} + +pub(super) fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) +} + +pub(super) fn anchor(s: &mut Session, p: &Value) -> Result { + let a = match str_param(p, "anchor").unwrap_or("top") { + "middle" | "center" | "ctr" => Anchor::Middle, + "bottom" | "b" => Anchor::Bottom, + _ => Anchor::Top, + }; + format_body(s, &move |b| b.anchor = Some(a)) +} + +pub(super) fn direction(s: &mut Session, p: &Value) -> Result { + let d = match str_param(p, "dir").unwrap_or("horizontal") { + "rotate90" | "vert" => TextDir::Vertical, + "rotate270" | "vert270" => TextDir::Vertical270, + "stacked" => TextDir::Stacked, + _ => TextDir::Horizontal, + }; + format_body(s, &move |b| b.vert = Some(d)) +} + +/// The edited or first selected shape's columns as drawn: the count and the spacing in points. +fn current_columns(s: &Session) -> (u32, f64) { + let Ok(st) = s.doc() else { return (1, 0.0) }; + let id = match &st.selection.text { + Some(t) if !t.notes => Some(t.shape), + _ => st.selection.shapes.first().copied(), + }; + let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) }; + let body = match crate::cmd::text::ctx_for(&st.doc, &st.selection) { + Some(c) => resolve::body(&c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + (body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0)) +} + +pub(super) fn columns(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.columns"; + let count = match p.get("count") { + None => None, + Some(v) => { + let n = v + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n)) + .ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?; + Some(n as u32) + } + }; + let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt); + if count.is_none() && spacing.is_none() { + let (count, spacing) = current_columns(s); + s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) }); + return ok(); + } + format_body(s, &move |b| { + if let Some(n) = count { + b.columns = Some(n); + } + if let Some(sp) = spacing { + b.col_spacing = Some(sp); + } + }) +} + +pub(super) fn autofit(s: &mut Session, p: &Value) -> Result { + let m = match str_param(p, "mode").unwrap_or("none") { + "shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }, + "resize" | "shape" => AutoFit::Shape, + _ => AutoFit::None, + }; + format_body(s, &move |b| b.autofit = Some(m)) +} + +pub(super) fn wrap(s: &mut Session, p: &Value) -> Result { + let on = bool_or(p, "on", true); + format_body(s, &move |b| b.wrap = Some(on)) +} + +pub(super) fn margins(s: &mut Session, p: &Value) -> Result { + let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")); + format_body(s, &move |bp| { + if l.is_some() { + bp.inset_l = l.map(|v| v.max(0.0)); + } + if t.is_some() { + bp.inset_t = t.map(|v| v.max(0.0)); + } + if r.is_some() { + bp.inset_r = r.map(|v| v.max(0.0)); + } + if b.is_some() { + bp.inset_b = b.map(|v| v.max(0.0)); + } + }) +} diff --git a/crates/engine/src/cmd/format/ruler.rs b/crates/engine/src/cmd/format/ruler.rs new file mode 100644 index 000000000..7d0b923cb --- /dev/null +++ b/crates/engine/src/cmd/format/ruler.rs @@ -0,0 +1,116 @@ +use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params}; +use super::*; + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +pub(super) fn ruler(s: &mut Session, _p: &Value) -> Result { + let props = current_para(s); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; + let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { + (false, Some(sh)) => { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection); + let x = match &ctx { + Some(c) => resolve::xfrm(c, sh), + None => sh.xfrm.unwrap_or_default(), + }; + let body = match &ctx { + Some(c) => resolve::body(c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); + (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) + } + _ => (0.0, 0.0, 0.0, true), + }; + let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + Ok(json!({ + "x": x, "y": y, "width": w, "rotated": rotated, + "marginLeft": props.margin_left.unwrap_or(0.0), + "indent": props.indent.unwrap_or(0.0), + "tabs": tabs, + "defaultTab": props.default_tab.unwrap_or(72.0), + })) +} + +const MAX_TABS: usize = 32; + +pub(super) fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); + let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => "l", + }; + let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); + let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); + let remove = fin(p.get("remove").and_then(Value::as_f64)); + // `set` replaces every stop: entries without a usable position are dropped, an unknown + // alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins). + let set = match p.get("set") { + None => None, + Some(Value::Array(items)) => { + if items.len() > 64 { + return Err(bad(CMD, "`set` has more than 64 tab stops")); + } + let mut out: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + return Err(bad(CMD, format!("tab stop {i} must be an object"))); + }; + let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else { + continue; + }; + let align = match obj.get("align").and_then(Value::as_str) { + None => "l", + Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, + }; + if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { + *prev = TabStop { pos, align: align.to_string() }; + } else { + out.push(TabStop { pos, align: align.to_string() }); + } + } + Some(out) + } + Some(_) => return Err(bad(CMD, "`set` must be an array")), + }; + let clear = bool_or(p, "clear", false); + let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); + let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear; + if !edits_stops && default_tab.is_none() { + // No fields: open the Tabs dialog on the caret's paragraph. + s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); + return ok(); + } + // The stops as the caret's paragraph shows them (inherited ones included), so editing a + // placeholder's tab that came from its layout works like editing one set on the paragraph. + let base = current_para(s).tabs.unwrap_or_default(); + let near = |a: f64, b: f64| (a - b).abs() < 0.5; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; + if let Some(at) = remove { + stops.retain(|t| !near(t.pos, at)); + } + if let Some((from, to)) = mv + && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) + { + t.pos = to; + } + if let Some((pos, align)) = add { + stops.retain(|t| !near(t.pos, pos)); + stops.push(TabStop { pos, align }); + } + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops.dedup_by(|a, b| near(a.pos, b.pos)); + stops.truncate(MAX_TABS); + format_para(s, &move |para| { + if edits_stops { + para.props.tabs = Some(stops.clone()); + } + if let Some(d) = default_tab { + para.props.default_tab = Some(d); + } + }) +} diff --git a/crates/engine/src/cmd/format/text_effects.rs b/crates/engine/src/cmd/format/text_effects.rs new file mode 100644 index 000000000..953f0fe77 --- /dev/null +++ b/crates/engine/src/cmd/format/text_effects.rs @@ -0,0 +1,137 @@ +use super::*; + +pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).shadow.is_some())); + let sh = deckcraft_model::style::Shadow { + color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)), + blur: 3.0, + dist: 2.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }; + format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None }) +} + +pub(super) fn text_outline(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color"); + let w = f64_or(p, "width", 0.75); + format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w))) +} + +pub(super) fn text_warp(s: &mut Session, p: &Value) -> Result { + let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?; + let clear = preset == "none" || preset == "textNoShape"; + if !clear && !deckcraft_geom::warp::is_supported(preset) { + return Err(bad("format.textWarp", format!("unknown text transform `{preset}`"))); + } + let adj = match p.get("adj") { + None => Vec::new(), + Some(Value::Array(arr)) => { + if arr.len() > 8 { + return Err(bad("format.textWarp", "at most 8 adjust values")); + } + let mut v = Vec::with_capacity(arr.len()); + for a in arr { + let Some(n) = a.as_f64().filter(|n| n.is_finite()) else { + return Err(bad("format.textWarp", "adjust values must be finite numbers")); + }; + v.push(n.clamp(-2.0e9, 2.0e9)); + } + v + } + Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")), + }; + let stored = if clear { None } else { Some(preset.to_string()) }; + format_body(s, &|b| { + b.warp.clone_from(&stored); + b.warp_adj.clone_from(&adj); + }) +} + +pub(super) fn case(s: &mut Session, p: &Value) -> Result { + let mode = str_param(p, "mode").unwrap_or("cycle").to_string(); + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let (mut a, mut b) = t.ordered(); + let body = crate::cmd::text::body_of(st, &t).cloned().unwrap_or_default(); + let collapsed = a == b; + if collapsed { + // Shift+F3 with a bare caret changes the word it is in and puts the caret at its start. + let (wa, wb) = ed::word_at(&body, a); + let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric); + if !(is_word && wa.1 <= a.1 && a.1 < wb.1) { + return ok(); + } + (a, b) = (wa, wb); + } + let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() }; + crate::cmd::text::edit_text(s, move |body, t| { + let last = body.paragraphs.len().saturating_sub(1); + for pi in a.0..=b.0.min(last) { + let Some(para) = body.paragraphs.get_mut(pi) else { continue }; + let n = para.char_len(); + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { n }; + let mut pos = 0; + let mut pieces = Vec::new(); + for (ri, r) in para.runs.iter().enumerate() { + // Breaks and fields are read for context (a break ends a word) but not rewritten. + let len = r.char_len(); + let rs = from.max(pos).saturating_sub(pos); + let re = to.min(pos + len).saturating_sub(pos); + if re > rs { + pieces.push((ri, rs, re)); + } + pos += len; + } + let mids: Vec = pieces + .iter() + .map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default()) + .collect(); + let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::>(), &m); + for (&(ri, rs, re), new) in pieces.iter().zip(changed) { + let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue }; + let pre: String = r.text.chars().take(rs).collect(); + let post: String = r.text.chars().skip(re).collect(); + r.text = format!("{pre}{new}{post}"); + } + } + if collapsed { + t.anchor = a; + t.caret = a; + } + })?; + return ok(); + } + let mode = if mode == "cycle" { + let all: String = + st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::>().join( + " +", + ); + ed::next_case(&all).to_string() + } else { + mode + }; + edit_shapes(s, &json!({}), "format.case", |sh| { + if let Some(t) = sh.text.as_mut() { + for para in &mut t.paragraphs { + // Breaks and fields are read for context (a break ends a word) but not rewritten. + let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect(); + let changed = ed::change_case_parts(&texts, &mode); + for (r, new) in para.runs.iter_mut().zip(changed) { + if r.kind == deckcraft_model::text::RunKind::Text { + r.text = new; + } + } + } + } + Ok(()) + }) +} From 97b98767f1713a8122db4e197bf4605f00b64d3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:56:29 +0000 Subject: [PATCH 50/90] refactor(engine): split cmd/insert.rs into cmd/insert/{picture,album,chart,text_fields,links}.rs --- crates/engine/src/cmd/insert.rs | 1549 +------------------ crates/engine/src/cmd/insert/album.rs | 724 +++++++++ crates/engine/src/cmd/insert/chart.rs | 218 +++ crates/engine/src/cmd/insert/links.rs | 116 ++ crates/engine/src/cmd/insert/picture.rs | 243 +++ crates/engine/src/cmd/insert/text_fields.rs | 243 +++ 6 files changed, 1559 insertions(+), 1534 deletions(-) create mode 100644 crates/engine/src/cmd/insert/album.rs create mode 100644 crates/engine/src/cmd/insert/chart.rs create mode 100644 crates/engine/src/cmd/insert/links.rs create mode 100644 crates/engine/src/cmd/insert/picture.rs create mode 100644 crates/engine/src/cmd/insert/text_fields.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 81b3426a3..76bc53597 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -14,6 +14,21 @@ use super::{ }; use crate::{EngineError, Result, Session}; +mod album; +mod chart; +mod links; +mod picture; +mod text_fields; + +use album::{photo_album, photo_album_edit}; +pub use chart::chart_type; +use chart::{chart, chart_data, chart_options, chart_type_cmd}; +use links::{action_button, hyperlink, shape_action}; +pub use picture::content_type; +pub(crate) use picture::media_bytes; +use picture::{audio, picture, video}; +use text_fields::{date_time, slide_number, symbol, word_art}; + pub fn specs() -> Vec { vec![ cmd!( @@ -316,880 +331,6 @@ fn text_box(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id})) } -/// Read media bytes from `data` (base64) or `path`. -pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { - if let Some(d) = str_param(p, "data") { - let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?; - return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes)); - } - if let Some(path) = str_param(p, "path") { - let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); - return Ok((name, bytes)); - } - Err(bad(cmd, "missing `path` or `data`")) -} - -pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { - let l = name.to_ascii_lowercase(); - if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { - "image/png" - } else if bytes.starts_with(&[0xFF, 0xD8]) { - "image/jpeg" - } else if bytes.starts_with(b"GIF8") { - "image/gif" - } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - "image/webp" - } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { - "audio/wav" - } else if bytes.starts_with(b"BM") { - "image/bmp" - } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { - "image/tiff" - } else if bytes.starts_with(b"fLaC") { - "audio/flac" - } else if bytes.starts_with(b"OggS") { - if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } - } else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 { - // ADTS (layer bits 00): AAC. - "audio/aac" - } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { - "audio/mpeg" - } else if bytes.starts_with(b"FORM") { - "audio/aiff" - } else if bytes.get(4..8) == Some(b"ftyp") { - if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") { - "audio/mp4" - } else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") { - "video/quicktime" - } else { - "video/mp4" - } - } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { - if l.ends_with(".mka") || l.ends_with(".weba") { - "audio/webm" - } else if l.ends_with(".mkv") { - "video/x-matroska" - } else { - "video/webm" - } - } else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { - if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" } - } else if bytes.starts_with(b"caff") { - "audio/x-caf" - } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { - let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .ok() - .and_then(|r| r.into_dimensions().ok()) - .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) - .unwrap_or((288.0, 216.0)); - let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); - (w * k, h * k) -} - -fn picture(s: &mut Session, p: &Value) -> Result { - let (name, bytes) = media_bytes(p, "insert.picture")?; - let ct = content_type(&name, &bytes); - if !ct.starts_with("image/") { - return Err(bad("insert.picture", format!("{name} is not a picture"))); - } - if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { - return Err(EngineError::Other(format!("{name}: the picture can't be read"))); - } - let (w, h) = fitted(s, &bytes); - // Into a selected empty picture/content placeholder, if there is one. - let target_ph = s.active().and_then(|d| { - d.selected_shapes() - .into_iter() - .find(|x| { - x.ph_type() - .is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body)) - && x.text.as_ref().is_none_or(|t| t.is_empty()) - }) - .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) - }); - let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { - Some((_, b)) => { - // Fill the placeholder, cropping the picture to its aspect. - b - } - None => default_rect(s, w, h), - }); - let crop = match (target_ph, rect_param(p, "rect")) { - (Some(_), None) => { - let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); - if ia > ba { - let c = (1.0 - ba / ia) / 2.0; - [c, 0.0, c, 0.0] - } else { - let c = (1.0 - ia / ba) / 2.0; - [0.0, c, 0.0, c] - } - } - _ => [0.0; 4], - }; - let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?; - let shape = Shape { - xfrm: Some(rect), - kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, - descr: name.clone(), - ..Default::default() - }; - if let Some((ph_id, _)) = target_ph { - // Replace the placeholder with the picture (it keeps the placeholder link). - let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); - s.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; - if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { - sh.kind = shape.kind.clone(); - sh.xfrm = Some(rect); - sh.ph = phv.map(|mut p| { - p.kind = deckcraft_model::PhType::Picture; - p - }); - sh.text = None; - sh.descr = name.clone(); - } - sel.shapes = vec![ph_id]; - Ok(()) - })?; - return Ok(json!({"id": ph_id})); - } - let id = add_shape(s, "insert.picture", shape, true)?; - Ok(json!({"id": id})) -} - -const ALBUM_MAX_ITEMS: usize = 48; -const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024; -const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024; -const ALBUM_MAX_TEXT: usize = 400; - -#[derive(Clone, Copy)] -struct AlbumLayout { - cols: usize, - rows: usize, - per: usize, - title: bool, - fit: bool, -} - -#[derive(Clone, Copy)] -enum AlbumFrame { - None, - Rect, - Rounded, - Simple, -} - -struct AlbumPic { - name: String, - bytes: Vec, - px_w: f64, - px_h: f64, - rot: f64, - brightness: f64, - contrast: f64, -} - -struct AlbumOpts { - layout_key: String, - frame_key: String, - layout: AlbumLayout, - frame: AlbumFrame, - captions: bool, - grayscale: bool, - theme: Option, -} - -enum AlbumItem { - Picture(AlbumPic), - Text(String), -} - -enum AlbumSlide { - Pictures(Vec), - Text(String), -} - -/// Insert a photo album after the current slide. -/// -/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one -/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit. -fn photo_album(s: &mut Session, p: &Value) -> Result { - if p.get("pictures").is_none_or(Value::is_null) { - s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) }); - return Ok(json!({"dialog": "photoAlbum"})); - } - let opts = album_opts(p)?; - let items = album_items(p)?; - commit_album(s, items, opts, None) -} - -fn photo_album_edit(s: &mut Session, p: &Value) -> Result { - if p.get("pictures").is_none_or(Value::is_null) { - let seed = album_seed(s)?; - let album = seed.get("album").cloned().unwrap_or(Value::Null); - s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed }); - return Ok(json!({"dialog": "photoAlbum", "album": album})); - } - let opts = album_opts(p)?; - let items = album_items(p)?; - let (id, ids) = album_target(s, p)?; - commit_album(s, items, opts, Some((id, ids))) -} - -fn commit_album(s: &mut Session, items: Vec, opts: AlbumOpts, replace: Option<(u32, Vec)>) -> Result { - let (snapshot, sel) = { - let st = s.doc()?; - (st.doc.clone(), st.selection.clone()) - }; - let result = (|| { - let album_id = match replace.as_ref().map(|(id, _)| *id) { - Some(id) if id != 0 => id, - _ => { - let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" }; - s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))? - } - }; - let at = if let Some((_, ids)) = &replace { - let st = s.doc()?; - let indices: Vec = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect(); - let Some(at) = indices.iter().copied().min() else { - return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); - }; - s.execute("slide.delete", &json!({"ids": ids}))?; - Some(at) - } else { - None - }; - let mut opts = opts; - if opts.theme.as_deref().unwrap_or("").is_empty() { - opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty()); - } - apply_theme_if_needed(s, opts.theme.as_deref())?; - insert_album(s, items, &opts, at, album_id) - })(); - if result.is_err() - && let Some(st) = s.active_mut() - { - st.doc = snapshot; - st.selection = sel; - } - result -} - -fn album_norm(s: &str) -> String { - s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() -} - -fn album_layout(p: &Value) -> Result { - let key = match p.get("layout") { - None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), - Some(Value::String(s)) => s.as_str(), - Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")), - }; - match album_norm(key).as_str() { - "fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }), - "1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), - "1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }), - "2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }), - "2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }), - "4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }), - "4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }), - _ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))), - } -} - -fn album_frame(p: &Value) -> Result { - let key = match p.get("frame") { - None => return Ok(AlbumFrame::None), - Some(Value::String(s)) => s.as_str(), - Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")), - }; - match album_norm(key).as_str() { - "none" => Ok(AlbumFrame::None), - "rect" | "rectangle" => Ok(AlbumFrame::Rect), - "rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded), - "simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple), - _ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))), - } -} - -fn album_captions(p: &Value) -> Result { - match p.get("captions") { - None => Ok(false), - Some(Value::Bool(v)) => Ok(*v), - Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")), - } -} - -fn album_bool(p: &Value, key: &str) -> Result { - match p.get(key) { - None | Some(Value::Null) => Ok(false), - Some(Value::Bool(v)) => Ok(*v), - Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))), - } -} - -fn layout_key_of(layout: AlbumLayout) -> String { - if layout.fit { - return "fit".into(); - } - let n = match layout.per { - 2 => "2", - 4 => "4", - _ => "1", - }; - if layout.title { format!("{n}title") } else { n.into() } -} - -fn frame_key_of(frame: AlbumFrame) -> &'static str { - match frame { - AlbumFrame::None => "none", - AlbumFrame::Rect => "rect", - AlbumFrame::Rounded => "rounded", - AlbumFrame::Simple => "simple", - } -} - -fn album_opts(p: &Value) -> Result { - let layout = album_layout(p)?; - let frame = album_frame(p)?; - let theme = match p.get("theme") { - None | Some(Value::Null) => None, - Some(Value::String(s)) if s.trim().is_empty() => None, - Some(Value::String(s)) => { - let name = s.trim(); - let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)); - let Some(t) = found else { - return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`"))); - }; - Some(t.name) - } - Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")), - }; - Ok(AlbumOpts { - layout_key: layout_key_of(layout), - frame_key: frame_key_of(frame).into(), - layout, - frame, - captions: album_captions(p)?, - grayscale: album_bool(p, "grayscale")?, - theme, - }) -} - -/// A finite number, or 0 when the key is absent. A present non-number is an error. -fn album_finite(item: &Value, key: &str) -> Result { - match item.get(key) { - None | Some(Value::Null) => Ok(0.0), - Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))), - } -} - -fn album_rot(item: &Value) -> Result { - let n = album_finite(item, "rot")?; - Ok(((n % 360.0) + 360.0) % 360.0) -} - -fn album_unit(item: &Value, key: &str) -> Result { - let n = album_finite(item, key)?; - if !(-1.0..=1.0).contains(&n) { - return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1"))); - } - Ok(n) -} - -fn album_items(p: &Value) -> Result> { - let Some(arr) = p.get("pictures").and_then(Value::as_array) else { - return Err(bad("insert.photoAlbum", "`pictures` must be a list")); - }; - if arr.is_empty() { - return Err(bad("insert.photoAlbum", "add at least one picture or text box")); - } - if arr.len() > ALBUM_MAX_ITEMS { - return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes")); - } - let mut items = Vec::with_capacity(arr.len()); - let mut total = 0usize; - for item in arr { - let Some(obj) = item.as_object() else { - return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box")); - }; - let has_media = obj.contains_key("data") || obj.contains_key("path"); - if !has_media { - let Some(text) = obj.get("text").and_then(Value::as_str) else { - return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`")); - }; - let text = text.trim(); - if text.is_empty() { - return Err(bad("insert.photoAlbum", "a text box needs some text")); - } - if text.chars().count() > ALBUM_MAX_TEXT { - return Err(bad("insert.photoAlbum", "text is longer than 400 characters")); - } - items.push(AlbumItem::Text(text.to_string())); - continue; - } - let (name, bytes) = media_bytes(item, "insert.photoAlbum")?; - if bytes.len() > ALBUM_MAX_BYTES { - return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB"))); - } - let next = total.saturating_add(bytes.len()); - if next > ALBUM_MAX_TOTAL { - return Err(bad("insert.photoAlbum", "the album is larger than 64 MB")); - } - total = next; - let ct = content_type(&name, &bytes); - if !ct.starts_with("image/") { - return Err(bad("insert.photoAlbum", format!("{name} is not a picture"))); - } - if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { - return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read"))); - } - let (px_w, px_h) = image_px(&bytes); - items.push(AlbumItem::Picture(AlbumPic { - name, - bytes, - px_w, - px_h, - rot: album_rot(item)?, - brightness: album_unit(item, "brightness")?, - contrast: album_unit(item, "contrast")?, - })); - } - Ok(items) -} - -fn image_px(bytes: &[u8]) -> (f64, f64) { - image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .ok() - .and_then(|r| r.into_dimensions().ok()) - .map(|(w, h)| (w as f64, h as f64)) - .filter(|(w, h)| *w > 0.0 && *h > 0.0) - .unwrap_or((4.0, 3.0)) -} - -fn file_stem(name: &str) -> String { - let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name); - let stem = match base.rsplit_once('.') { - Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s, - _ => base, - }; - if stem.is_empty() { "Picture".to_string() } else { stem.to_string() } -} - -fn album_plan(items: Vec, per: usize) -> Vec { - let per = per.max(1); - let mut out = Vec::new(); - let mut batch = Vec::new(); - for item in items { - match item { - AlbumItem::Text(text) => { - if !batch.is_empty() { - out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); - } - out.push(AlbumSlide::Text(text)); - } - AlbumItem::Picture(pic) => { - batch.push(pic); - if batch.len() >= per { - out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); - } - } - } - } - if !batch.is_empty() { - out.push(AlbumSlide::Pictures(batch)); - } - out -} - -struct Placed { - pic: [f64; 4], - caption: Option<[f64; 4]>, -} - -fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) { - let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 }; - let box_w = box_w.max(1.0); - let box_h = box_h.max(1.0); - if !aspect.is_finite() || aspect <= 0.0 { - return (box_w, box_h); - } - if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) } -} - -fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec) { - let margin = if layout.fit { 18.0 } else { 36.0 }; - let title_band = if layout.title { 48.0 } else { 0.0 }; - let gap = 12.0; - let cap_h = 22.0; - let cap_gap = 4.0; - let left = margin; - let top = margin + title_band; - let grid_w = (width - margin - left).max(1.0); - let grid_h = (height - margin - top).max(1.0); - let cols = layout.cols.max(1); - let rows = layout.rows.max(1); - let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0); - let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0); - let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h }; - let mut placed = Vec::with_capacity(n); - for i in 0..n { - let col = i % cols; - let row = i / cols; - if row >= rows { - break; - } - let cell_x = left + col as f64 * (cell_w + gap); - let cell_y = top + row as f64 * (cell_h + gap); - placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None }); - } - let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None }; - (title, placed) -} - -fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec) { - let (title, slots) = place_album(width, height, layout, pics.len(), captions); - let placed = pics - .iter() - .zip(slots) - .map(|(pic, slot)| { - let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h); - let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0; - let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0; - let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None }; - Placed { pic: [x, y, dw, dh], caption } - }) - .collect(); - (title, placed) -} - -fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> { - let params = match frame { - AlbumFrame::None => return Ok(()), - AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}), - AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}), - AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}), - }; - s.execute("shape.line", ¶ms)?; - Ok(()) -} - -struct AlbumMark { - id: u32, - layout: String, - frame: String, - captions: bool, - grayscale: bool, - theme: String, -} - -fn parse_album_mark(raw: &str) -> Option { - let rest = raw.strip_prefix("deckcraft-album:")?; - let (id_str, rest) = rest.split_once(';').unwrap_or((rest, "")); - let id = id_str.parse::().ok().filter(|id| *id != 0)?; - let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() }; - if !rest.is_empty() { - for part in rest.split(';') { - let Some((k, v)) = part.split_once('=') else { continue }; - match k { - "layout" => mark.layout = v.to_string(), - "frame" => mark.frame = v.to_string(), - "captions" => mark.captions = v == "1", - "gray" => mark.grayscale = v == "1", - "theme" => mark.theme = v.to_string(), - _ => {} - } - } - } - Some(mark) -} - -fn album_marker(id: u32, opts: &AlbumOpts) -> String { - let theme = opts.theme.as_deref().unwrap_or(""); - format!( - "deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}", - opts.layout_key, - opts.frame_key, - if opts.captions { 1 } else { 0 }, - if opts.grayscale { 1 } else { 0 }, - ) -} - -fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> { - let marker = album_marker(album_id, opts); - s.edit(|doc, sel| { - let Some(slide) = doc.slides.get_mut(sel.slide) else { - return Err(bad("insert.photoAlbum", "no slide to mark")); - }; - let slide = std::sync::Arc::make_mut(slide); - if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) { - slide.raw_ext = Some(marker); - } - slide.name = "Photo Album".into(); - Ok(()) - }) -} - -fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> { - let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) }; - let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default(); - if current.eq_ignore_ascii_case(name) { - return Ok(()); - } - s.execute("design.theme", &json!({"name": name}))?; - Ok(()) -} - -fn album_id_param(p: &Value) -> Option { - match p.get("album") { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0), - Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v != 0), - _ => None, - } -} - -fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec { - doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect() -} - -fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec { - if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") { - return Vec::new(); - } - let mut start = index; - while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") { - start -= 1; - } - let mut end = index; - while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") { - end += 1; - } - doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect() -} - -/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker). -fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec)> { - let st = s.doc()?; - if let Some(id) = album_id_param(p) { - let ids = slides_with_album(&st.doc, id); - if ids.is_empty() { - return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); - } - return Ok((id, ids)); - } - let Some(slide) = st.doc.slides.get(st.selection.slide) else { - return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); - }; - if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) { - let ids = slides_with_album(&st.doc, mark.id); - if ids.is_empty() { - return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); - } - return Ok((mark.id, ids)); - } - if slide.name == "Photo Album" { - let ids = album_name_run(&st.doc, st.selection.slide); - if !ids.is_empty() { - return Ok((0, ids)); - } - } - Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")) -} - -fn pic_count(slide: &deckcraft_model::Slide) -> usize { - slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count() -} - -fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) { - let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1); - let per = if max_pics >= 3 { - "4" - } else if max_pics == 2 { - "2" - } else { - "1" - }; - let title = - slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0))); - let layout = if title { format!("{per}title") } else { per.into() }; - let width = slides.iter().find_map(|s| { - s.shapes.iter().find_map(|sh| match &sh.kind { - ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width), - _ => None, - }) - }); - let frame = match width { - None => "none", - Some(w) if (w - 6.0).abs() < 0.2 => "simple", - Some(w) if (w - 2.25).abs() < 0.2 => "rounded", - Some(w) if w < 0.2 => "none", - Some(_) => "rect", - }; - let captions = slides.iter().any(|s| { - let bottoms: Vec = - s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect(); - !bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b))) - }); - let mut any = false; - let mut gray = true; - for s in slides { - for sh in &s.shapes { - if let ShapeKind::Picture { fill } = &sh.kind { - any = true; - gray &= fill.adjust.grayscale; - } - } - } - (layout, frame.into(), captions, any && gray, theme_now.to_string()) -} - -fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec { - let mut items = Vec::new(); - for slide in slides { - let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); - if pics.is_empty() { - let text = slide - .shapes - .iter() - .filter(|sh| sh.text_box) - .filter_map(|sh| sh.text.as_ref().map(|t| t.text())) - .find(|t| !t.trim().is_empty()) - .unwrap_or_default(); - if !text.trim().is_empty() { - items.push(json!({"text": text.trim()})); - } - continue; - } - for sh in pics { - let ShapeKind::Picture { fill } = &sh.kind else { continue }; - let media = doc.media(fill.media); - let name = if !sh.descr.is_empty() { - sh.descr.clone() - } else { - media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into()) - }; - items.push(json!({ - "name": name, - "data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(), - "rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0), - "brightness": fill.adjust.brightness, - "contrast": fill.adjust.contrast, - })); - } - } - items -} - -fn album_seed(s: &Session) -> Result { - let (id, ids) = album_target(s, &Value::Null)?; - let st = s.doc()?; - let slides: Vec<&deckcraft_model::Slide> = - ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect(); - let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into()); - let inferred = infer_album(&slides, &theme_now); - let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark)); - let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark { - ( - if mark.layout.is_empty() { inferred.0 } else { mark.layout }, - if mark.frame.is_empty() { inferred.1 } else { mark.frame }, - mark.captions, - mark.grayscale, - if mark.theme.is_empty() { theme_now } else { mark.theme }, - ) - } else { - inferred - }; - Ok(json!({ - "album": if id == 0 { Value::Null } else { json!(id) }, - "layout": layout, - "frame": frame, - "captions": captions, - "grayscale": grayscale, - "theme": theme, - "items": items_from_slides(&st.doc, &slides), - })) -} - -fn insert_album(s: &mut Session, items: Vec, opts: &AlbumOpts, at: Option, album_id: u32) -> Result { - let (width, height, first) = { - let st = s.doc()?; - let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) }); - (st.doc.slide_size.width, st.doc.slide_size.height, first) - }; - let layout = opts.layout; - let frame = opts.frame; - let captions = opts.captions; - let plan = album_plan(items, layout.per); - let mut slides = 0usize; - let mut pictures = 0usize; - let mut pin = at; - for slide in plan { - if let Some(index) = pin.take() { - s.execute("slide.new", &json!({"layout": "blank", "at": index}))?; - } else { - s.execute("slide.new", &json!({"layout": "blank"}))?; - } - stamp_album_slide(s, album_id, opts)?; - slides += 1; - match slide { - AlbumSlide::Text(text) => { - let rect = if layout.title { - [36.0, 18.0, (width - 72.0).max(36.0), 48.0] - } else { - [36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)] - }; - s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?; - } - AlbumSlide::Pictures(pics) => { - let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default(); - let (title, placed) = place_pictures(width, height, layout, &pics, captions); - for (pic, cell) in pics.iter().zip(placed) { - let data = base64_encode(&pic.bytes); - let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?; - let Some(id) = inserted.get("id").and_then(Value::as_u64) else { - return Err(bad("insert.photoAlbum", "the picture was not inserted")); - }; - apply_frame(s, frame, id)?; - if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 { - s.execute( - "picture.adjust", - &json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}), - )?; - } - if pic.rot > 0.01 { - s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?; - } - pictures += 1; - if let Some(cap) = cell.caption { - s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?; - } - } - if let Some(rect) = title { - s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?; - } - } - } - } - s.execute("slide.go", &json!({"index": first}))?; - Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id})) -} - fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); @@ -1211,666 +352,6 @@ fn table(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id})) } -pub fn chart_type(name: &str) -> ChartType { - match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { - "bar" | "clusteredbar" => ChartType::Bar, - "stackedbar" => ChartType::StackedBar, - "percentbar" => ChartType::PercentBar, - "line" => ChartType::Line, - "linemarkers" | "linewithmarkers" => ChartType::LineMarkers, - "stackedline" => ChartType::StackedLine, - "area" => ChartType::Area, - "stackedarea" => ChartType::StackedArea, - "pie" => ChartType::Pie, - "doughnut" | "donut" => ChartType::Doughnut, - "scatter" | "xy" => ChartType::Scatter, - "bubble" => ChartType::Bubble, - "radar" => ChartType::Radar, - "filledradar" => ChartType::FilledRadar, - "stackedcolumn" => ChartType::StackedColumn, - "percentcolumn" => ChartType::PercentColumn, - "waterfall" => ChartType::Waterfall, - "funnel" => ChartType::Funnel, - "histogram" => ChartType::Histogram, - "pareto" => ChartType::Pareto, - "treemap" => ChartType::Treemap, - "sunburst" => ChartType::Sunburst, - "boxwhisker" | "box" => ChartType::BoxWhisker, - "stock" => ChartType::Stock, - "surface" => ChartType::Surface, - "combo" => ChartType::Combo, - _ => ChartType::Column, - } -} - -fn chart(s: &mut Session, p: &Value) -> Result { - let kind = chart_type(str_param(p, "type").unwrap_or("column")); - let mut c = Chart::sample(kind); - if let Some(cats) = p.get("categories").and_then(Value::as_array) { - c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect(); - } - if let Some(series) = p.get("series").and_then(Value::as_array) { - c.series = series - .iter() - .map(|sv| deckcraft_model::chart::Series { - name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), - values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), - ..Default::default() - }) - .collect(); - } - if let Some(t) = p.get("title") { - c.title = t.as_str().map(String::from); - } - let size = s.doc()?.doc.slide_size; - let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); - let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; - Ok(json!({"id": id})) -} - -fn media(s: &mut Session, p: &Value, video: bool) -> Result { - let cmd = if video { "insert.video" } else { "insert.audio" }; - let (name, bytes) = media_bytes(p, cmd)?; - let ct = content_type(&name, &bytes); - let bytes = std::sync::Arc::new(bytes); - let info = deckcraft_media::probe(&bytes); - let has_video = info.as_ref().is_ok_and(|i| i.video.is_some()); - let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some()); - match &info { - Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))), - Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))), - // Not something we can read: embed it anyway when the type says it's media (it still - // round-trips through PPTX), otherwise refuse. - Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))), - Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))), - _ => {} - } - let (duration_ms, width, height, playable) = match &info { - Ok(i) => { - let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)); - (i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable()) - } - Err(_) => (0, 0, 0, false), - }; - let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); - let warning = if !playable { - Some(match &info { - Ok(i) => { - let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); - format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) - } - Err(e) => format!("{name} was inserted, but it can't be played ({e})"), - }) - } else if video && !decodable_video { - // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame - // can ever show — say so instead of leaving an unexplained black box (#1237). - info.as_ref() - .ok() - .and_then(|i| i.video.as_ref()) - .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) - } else { - None - }; - let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None }; - let size = s.doc()?.doc.slide_size; - let rect = rect_param(p, "rect").unwrap_or_else(|| { - if video { - // Natural size (96 dpi), fitted inside 90 % of the slide and centred. - let (w, h) = - if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) }; - let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0); - let (w, h) = (w * k, h * k); - Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) - } else { - Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) - } - }); - let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); - let (media, poster) = s.edit(|doc, _| { - let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?; - let poster = match poster_png { - Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?), - None => None, - }; - Ok((media, poster)) - })?; - let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; - let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; - if let Some(w) = &warning { - s.ui_requests.push(crate::UiRequest::Message { text: w.clone() }); - } - let mut r = - json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning}); - if let Ok(i) = &info { - r["probe"] = super::media::probe_json(i); - } - Ok(r) -} - -fn audio(s: &mut Session, p: &Value) -> Result { - media(s, p, false) -} -fn video(s: &mut Session, p: &Value) -> Result { - media(s, p, true) -} - -fn word_art(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").unwrap_or("Your text here"); - let size = s.doc()?.doc.slide_size; - let style = usize_param(p, "style").unwrap_or(0); - let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text); - if let Some(t) = shape.text.as_mut() { - for para in &mut t.paragraphs { - para.props.align = Some(deckcraft_model::text::Align::Center); - for r in &mut para.runs { - r.props.size = Some(54.0); - r.props.bold = Some(style % 2 == 1); - r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { - 0 => SchemeSlot::Tx1, - 1 => SchemeSlot::Accent1, - 2 => SchemeSlot::Accent2, - _ => SchemeSlot::Accent4, - }))); - if style % 3 == 2 { - r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); - } - } - } - } - let id = add_shape(s, "insert.wordArt", shape, true)?; - super::text::fit_text_box(s, id)?; - Ok(json!({"id": id})) -} - -fn field_run(s: &mut Session, field: &str, text: &str) -> Result { - if s.doc()?.selection.text.is_some() { - return super::text::insert_field(s, field, text); - } - // Not editing: turn on the footer field for the slide (Header & Footer). - let key = if field == "slidenum" { "slideNumber" } else { "date" }; - s.execute("design.headerFooter", &json!({key: true})) -} - -fn slide_number(s: &mut Session, _p: &Value) -> Result { - field_run(s, "slidenum", "‹#›") -} - -const DATE_FORMATS: [&str; 13] = [ - "datetime1", - "datetime2", - "datetime3", - "datetime4", - "datetime5", - "datetime6", - "datetime7", - "datetime8", - "datetime9", - "datetime10", - "datetime11", - "datetime12", - "datetime13", -]; - -struct DateOpts { - format: String, - update: bool, - /// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language. - lang: Option, - sample: String, -} - -/// `true` when the call carries a format, update flag or language (apply it). Empty params open -/// the dialog instead. -fn date_time_apply(p: &Value) -> bool { - p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language")) -} - -fn canon_lang(raw: &str) -> Option<&'static str> { - match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { - "en" | "en-us" => Some("en-US"), - "en-gb" => Some("en-GB"), - "fr" | "fr-fr" => Some("fr-FR"), - "ja" | "ja-jp" => Some("ja-JP"), - "sv" | "sv-se" => Some("sv-SE"), - _ => None, - } -} - -fn date_opts(p: &Value) -> Result { - if p.get("format").is_some() { - let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f)); - if f.is_none() { - return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13")); - } - } - if p.get("update").is_some() && bool_param(p, "update").is_none() { - return Err(bad("insert.dateTime", "`update` must be true or false")); - } - let lang = if p.get("language").is_some() { - let raw = str_param(p, "language").filter(|l| !l.trim().is_empty()); - let Some(raw) = raw else { - return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE")); - }; - Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string()) - } else { - None - }; - let format = str_param(p, "format").unwrap_or("datetime1").to_string(); - let sample = date_sample(&format, lang.as_deref())?; - Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample }) -} - -/// The text `format` shows now in `lang`. Errors when that language has no names for the format -/// (month names and AM/PM are English only). An empty string where there is no clock (wasm). -fn date_sample(format: &str, lang: Option<&str>) -> Result { - use deckcraft_text::datetime::{DateTime, field_text}; - let probe = DateTime::new(2026, 10, 9, 20, 5, 7); - if field_text(format, lang, probe).is_none() { - return Err(bad("insert.dateTime", "this format isn't available in that language")); - } - Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default()) -} - -fn date_time(s: &mut Session, p: &Value) -> Result { - if !date_time_apply(p) { - s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) }); - return ok(); - } - let opts = date_opts(p)?; - let (doc, sel) = { - let st = s.doc()?; - (st.doc.clone(), st.selection.clone()) - }; - let r = apply_date(s, &opts); - if r.is_err() - && let Some(st) = s.active_mut() - { - st.doc = doc; - st.selection = sel; - } - r -} - -fn apply_date(s: &mut Session, o: &DateOpts) -> Result { - if s.doc()?.selection.text.is_some() { - if o.update { - super::text::insert_field(s, &o.format, &o.sample)?; - if let Some(lang) = &o.lang { - stamp_caret_lang(s, lang)?; - } - } else { - insert_plain(s, &o.sample, o.lang.as_deref())?; - } - return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); - } - // Not editing: the slide's date placeholder (this slide only). A layout with no date - // placeholder gets a text box instead, so the chosen format is still inserted. - s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; - if !write_date_placeholder(s, o)? { - return insert_date_box(s, o); - } - Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) -} - -/// Point the current slide's date placeholder at this format (a field, or the sample as plain text). -fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { - let format = o.format.clone(); - let sample = o.sample.clone(); - let lang = o.lang.clone(); - let update = o.update; - s.edit(|doc, sel| { - let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) }; - let sl = std::sync::Arc::make_mut(sl); - let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else { - return Ok(false); - }; - let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); - if let Some(lang) = lang { - props.lang = Some(lang); - } - let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; - let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() }; - let body = sh.text.clone().unwrap_or_default(); - sh.text = Some(TextBody { paragraphs: vec![para], ..body }); - Ok(true) - }) -} - -fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result { - let size = s.doc()?.doc.slide_size; - let y = (size.height - 48.0).max(0.0); - s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?; - if o.update { - super::text::insert_field(s, &o.format, &o.sample)?; - } else { - super::text::insert_text(s, &o.sample, None)?; - } - if let Some(lang) = &o.lang { - stamp_caret_lang(s, lang)?; - } - Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample})) -} - -/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge, -/// so the date stays its own run next to the surrounding text. -fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> { - let text = text.to_string(); - let lang = lang.map(str::to_string); - super::text::edit_text(s, move |body, t| { - let (a, b) = t.ordered(); - let at = deckcraft_model::edit::delete(body, a, b); - let mut props = deckcraft_model::edit::props_at(body, at); - if let Some(lang) = lang.clone() { - props.lang = Some(lang); - } - let c = deckcraft_model::edit::insert(body, at, &text, Some(props)); - t.anchor = c; - t.caret = c; - })?; - Ok(()) -} - -/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates. -fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> { - let lang = lang.to_string(); - super::text::edit_text(s, move |body, t| { - let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return }; - let mut at = 0usize; - for r in &mut p.runs { - let n = r.char_len(); - if n > 0 && at < t.caret.1 && t.caret.1 <= at + n { - r.props.lang = Some(lang); - return; - } - at += n; - } - })?; - Ok(()) -} - -fn symbol(s: &mut Session, p: &Value) -> Result { - let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string(); - if s.doc()?.selection.text.is_none() { - return Err(bad("insert.symbol", "place the insertion point in text first")); - } - super::text::insert_text(s, &t, None) -} - -fn hyperlink(s: &mut Session, p: &Value) -> Result { - use deckcraft_model::text::{Action, Hyperlink}; - // `display`: what a caret with nothing to link inserts, the address or the slide's title. - let (action, display) = if let Some(u) = str_param(p, "url") { - (Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string()) - } else if let Some(i) = usize_param(p, "slide") { - let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; - let title = slide.title(); - (Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title }) - } else { - return Err(bad("insert.hyperlink", "missing `url` or `slide`")); - }; - let display = str_param(p, "text").map_or(display, str::to_string); - let link = Hyperlink { - action, - tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), - highlight_click: bool_or(p, "highlightClick", false), - sound: None, - stop_sound: false, - zoom_return: None, - }; - if s.doc()?.selection.text.is_some() { - return super::text::link_text(s, link, &display); - } - edit_shapes(s, p, "insert.hyperlink", |sh| { - sh.click = Some(link.clone()); - Ok(()) - }) -} - -/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does -/// in a show. -fn shape_action(s: &mut Session, p: &Value) -> Result { - use deckcraft_model::text::{Action, Hyperlink}; - const CMD: &str = "shape.action"; - let hover = match str_param(p, "on").unwrap_or("click") { - "click" => false, - "hover" | "over" => true, - o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))), - }; - let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`"))); - let action = match str_param(p, "action").unwrap_or("none") { - "none" => None, - "next" => Some(Action::NextSlide), - "previous" => Some(Action::PreviousSlide), - "first" => Some(Action::FirstSlide), - "last" => Some(Action::LastSlide), - "lastViewed" => Some(Action::LastViewed), - "endShow" => Some(Action::EndShow), - "playMedia" => Some(Action::PlayMedia), - "url" => Some(Action::Url { url: need("url")? }), - "customShow" => { - let name = need("show")?; - if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { - return Err(bad(CMD, format!("no custom show `{name}`"))); - } - Some(Action::CustomShow { name, returns: true }) - } - "program" => Some(Action::Program { path: need("path")? }), - "slide" => { - let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?; - let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?; - Some(Action::Slide { slide: slide.id }) - } - a => return Err(bad(CMD, format!("unknown action `{a}`"))), - }; - let highlight = bool_param(p, "highlight").unwrap_or(true); - let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); - let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None }); - edit_shapes(s, p, CMD, |sh| { - if hover { - sh.hover = link.clone(); - } else { - sh.click = link.clone(); - } - Ok(()) - }) -} - -fn action_button(s: &mut Session, p: &Value) -> Result { - use deckcraft_model::text::{Action, Hyperlink}; - let kind = str_param(p, "kind").unwrap_or("custom"); - let (preset, action) = match kind { - "back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)), - "forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)), - "beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)), - "end" => ("actionButtonEnd", Some(Action::LastSlide)), - "home" => ("actionButtonHome", Some(Action::FirstSlide)), - "information" => ("actionButtonInformation", None), - "return" => ("actionButtonReturn", Some(Action::LastViewed)), - "movie" => ("actionButtonMovie", None), - "document" => ("actionButtonDocument", None), - "sound" => ("actionButtonSound", None), - "help" => ("actionButtonHelp", None), - _ => ("actionButtonBlank", None), - }; - let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0)); - let shape = Shape { - xfrm: Some(rect), - geom: Geom::preset(preset), - style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), - click: action.map(|a| Hyperlink { - action: a, - tooltip: String::new(), - highlight_click: true, - sound: None, - stop_sound: false, - zoom_return: None, - }), - ..Default::default() - }; - let id = add_shape(s, "insert.actionButton", shape, true)?; - Ok(json!({"id": id})) -} - -#[allow(dead_code)] -fn _kinds() -> RunKind { - RunKind::Text -} - -fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { - edit_shapes(s, p, cmd, |sh| match &mut sh.kind { - ShapeKind::Chart(c) => f(c), - _ => Err(bad(cmd, "not a chart")), - }) -} - -fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { - let k = chart_type(str_param(p, "type").unwrap_or("column")); - with_chart(s, p, "chart.type", |c| { - c.kind = k; - c.vary_colors = - matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) - || c.series.len() == 1 && c.vary_colors; - if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { - c.gridlines = true; - } - c.raw = None; - Ok(()) - }) -} - -fn chart_data(s: &mut Session, p: &Value) -> Result { - let cats: Option> = p - .get("categories") - .and_then(Value::as_array) - .map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect()); - let series: Option> = p.get("series").and_then(Value::as_array).map(|a| { - a.iter() - .map(|sv| deckcraft_model::chart::Series { - name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), - values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(), - ..Default::default() - }) - .collect() - }); - let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { - a.iter() - .take(deckcraft_model::chart::MAX_LEVELS) - .map(|l| { - l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() - }) - .collect() - }); - with_chart(s, p, "chart.data", |c| { - if let Some(cs) = &cats { - c.categories = cs.clone(); - } - if let Some(l) = &levels { - c.category_levels = l.clone(); - } - if let Some(se) = &series { - // Keep series formatting by position. - let old = std::mem::take(&mut c.series); - c.series = se - .iter() - .enumerate() - .map(|(i, n)| { - let mut x = old.get(i).cloned().unwrap_or_default(); - x.name = n.name.clone(); - x.values = n.values.clone(); - x - }) - .collect(); - } - c.raw = None; - Ok(()) - }) -} - -fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { - if let Some(n) = bin_count { - c.bin_count = n; - if n.is_some() { - c.bin_size = None; - } - } - if let Some(w) = bin_size { - c.bin_size = w; - if w.is_some() { - c.bin_count = None; - } - } -} - -fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { - if let Some(t) = subtotals { - let n = c.series.first().map_or(0, |s| s.values.len()); - let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); - t.sort_unstable(); - t.dedup(); - c.subtotals = t; - } - if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { - c.up_down_bars = b; - c.raw = None; - } - if let Some(q) = p.get("quartiles").and_then(Value::as_str) { - c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); - c.raw = None; - } -} - -fn chart_options(s: &mut Session, p: &Value) -> Result { - // Histogram bins: a count or a width (setting one clears the other; null for automatic). - let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); - let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); - if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { - return Err(bad("chart.options", "binSize must be a positive number")); - } - // Waterfall total points. - let subtotals: Option> = - p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); - // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's - // default (tx1 at lumMod 65 % / lumOff 35 %). - let text_color = match p.get("textColor") { - None => None, - Some(Value::Null) => Some(None), - Some(v) => { - Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) - } - }; - with_chart(s, p, "chart.options", |c| { - if let Some(t) = &text_color { - c.text_color = t.clone(); - } - apply_bin_options(c, bin_count, bin_size); - apply_subtotals_and_bars(c, p, subtotals.as_deref()); - if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { - c.raw = None; - } - if let Some(t) = p.get("title") { - c.title = t.as_str().map(String::from); - } - if let Some(l) = p.get("legend") { - c.legend = l.as_str().map(String::from); - } - if let Some(v) = bool_param(p, "dataLabels") { - c.data_labels = v; - // A series' own setting would otherwise keep overriding the chart-wide switch. - for series in &mut c.series { - series.data_labels = None; - } - } - if let Some(v) = bool_param(p, "gridlines") { - c.gridlines = v; - } - if let Some(v) = str_param(p, "palette") { - c.palette = v.to_string(); - } - c.raw = None; - Ok(()) - }) -} - /// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with /// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/insert/album.rs b/crates/engine/src/cmd/insert/album.rs new file mode 100644 index 000000000..b06f3e903 --- /dev/null +++ b/crates/engine/src/cmd/insert/album.rs @@ -0,0 +1,724 @@ +use super::picture::{content_type, media_bytes}; +use super::*; + +const ALBUM_MAX_ITEMS: usize = 48; + +const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024; + +const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024; + +const ALBUM_MAX_TEXT: usize = 400; + +#[derive(Clone, Copy)] +struct AlbumLayout { + cols: usize, + rows: usize, + per: usize, + title: bool, + fit: bool, +} + +#[derive(Clone, Copy)] +enum AlbumFrame { + None, + Rect, + Rounded, + Simple, +} + +struct AlbumPic { + name: String, + bytes: Vec, + px_w: f64, + px_h: f64, + rot: f64, + brightness: f64, + contrast: f64, +} + +struct AlbumOpts { + layout_key: String, + frame_key: String, + layout: AlbumLayout, + frame: AlbumFrame, + captions: bool, + grayscale: bool, + theme: Option, +} + +enum AlbumItem { + Picture(AlbumPic), + Text(String), +} + +enum AlbumSlide { + Pictures(Vec), + Text(String), +} + +/// Insert a photo album after the current slide. +/// +/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one +/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit. +pub(super) fn photo_album(s: &mut Session, p: &Value) -> Result { + if p.get("pictures").is_none_or(Value::is_null) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) }); + return Ok(json!({"dialog": "photoAlbum"})); + } + let opts = album_opts(p)?; + let items = album_items(p)?; + commit_album(s, items, opts, None) +} + +pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result { + if p.get("pictures").is_none_or(Value::is_null) { + let seed = album_seed(s)?; + let album = seed.get("album").cloned().unwrap_or(Value::Null); + s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed }); + return Ok(json!({"dialog": "photoAlbum", "album": album})); + } + let opts = album_opts(p)?; + let items = album_items(p)?; + let (id, ids) = album_target(s, p)?; + commit_album(s, items, opts, Some((id, ids))) +} + +fn commit_album(s: &mut Session, items: Vec, opts: AlbumOpts, replace: Option<(u32, Vec)>) -> Result { + let (snapshot, sel) = { + let st = s.doc()?; + (st.doc.clone(), st.selection.clone()) + }; + let result = (|| { + let album_id = match replace.as_ref().map(|(id, _)| *id) { + Some(id) if id != 0 => id, + _ => { + let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" }; + s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))? + } + }; + let at = if let Some((_, ids)) = &replace { + let st = s.doc()?; + let indices: Vec = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect(); + let Some(at) = indices.iter().copied().min() else { + return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); + }; + s.execute("slide.delete", &json!({"ids": ids}))?; + Some(at) + } else { + None + }; + let mut opts = opts; + if opts.theme.as_deref().unwrap_or("").is_empty() { + opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty()); + } + apply_theme_if_needed(s, opts.theme.as_deref())?; + insert_album(s, items, &opts, at, album_id) + })(); + if result.is_err() + && let Some(st) = s.active_mut() + { + st.doc = snapshot; + st.selection = sel; + } + result +} + +fn album_norm(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() +} + +fn album_layout(p: &Value) -> Result { + let key = match p.get("layout") { + None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")), + }; + match album_norm(key).as_str() { + "fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }), + "1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), + "1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }), + "2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }), + "2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }), + "4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }), + "4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }), + _ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))), + } +} + +fn album_frame(p: &Value) -> Result { + let key = match p.get("frame") { + None => return Ok(AlbumFrame::None), + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")), + }; + match album_norm(key).as_str() { + "none" => Ok(AlbumFrame::None), + "rect" | "rectangle" => Ok(AlbumFrame::Rect), + "rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded), + "simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple), + _ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))), + } +} + +fn album_captions(p: &Value) -> Result { + match p.get("captions") { + None => Ok(false), + Some(Value::Bool(v)) => Ok(*v), + Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")), + } +} + +fn album_bool(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(v)) => Ok(*v), + Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))), + } +} + +fn layout_key_of(layout: AlbumLayout) -> String { + if layout.fit { + return "fit".into(); + } + let n = match layout.per { + 2 => "2", + 4 => "4", + _ => "1", + }; + if layout.title { format!("{n}title") } else { n.into() } +} + +fn frame_key_of(frame: AlbumFrame) -> &'static str { + match frame { + AlbumFrame::None => "none", + AlbumFrame::Rect => "rect", + AlbumFrame::Rounded => "rounded", + AlbumFrame::Simple => "simple", + } +} + +fn album_opts(p: &Value) -> Result { + let layout = album_layout(p)?; + let frame = album_frame(p)?; + let theme = match p.get("theme") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s.trim().is_empty() => None, + Some(Value::String(s)) => { + let name = s.trim(); + let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)); + let Some(t) = found else { + return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`"))); + }; + Some(t.name) + } + Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")), + }; + Ok(AlbumOpts { + layout_key: layout_key_of(layout), + frame_key: frame_key_of(frame).into(), + layout, + frame, + captions: album_captions(p)?, + grayscale: album_bool(p, "grayscale")?, + theme, + }) +} + +/// A finite number, or 0 when the key is absent. A present non-number is an error. +fn album_finite(item: &Value, key: &str) -> Result { + match item.get(key) { + None | Some(Value::Null) => Ok(0.0), + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))), + } +} + +fn album_rot(item: &Value) -> Result { + let n = album_finite(item, "rot")?; + Ok(((n % 360.0) + 360.0) % 360.0) +} + +fn album_unit(item: &Value, key: &str) -> Result { + let n = album_finite(item, key)?; + if !(-1.0..=1.0).contains(&n) { + return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1"))); + } + Ok(n) +} + +fn album_items(p: &Value) -> Result> { + let Some(arr) = p.get("pictures").and_then(Value::as_array) else { + return Err(bad("insert.photoAlbum", "`pictures` must be a list")); + }; + if arr.is_empty() { + return Err(bad("insert.photoAlbum", "add at least one picture or text box")); + } + if arr.len() > ALBUM_MAX_ITEMS { + return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes")); + } + let mut items = Vec::with_capacity(arr.len()); + let mut total = 0usize; + for item in arr { + let Some(obj) = item.as_object() else { + return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box")); + }; + let has_media = obj.contains_key("data") || obj.contains_key("path"); + if !has_media { + let Some(text) = obj.get("text").and_then(Value::as_str) else { + return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`")); + }; + let text = text.trim(); + if text.is_empty() { + return Err(bad("insert.photoAlbum", "a text box needs some text")); + } + if text.chars().count() > ALBUM_MAX_TEXT { + return Err(bad("insert.photoAlbum", "text is longer than 400 characters")); + } + items.push(AlbumItem::Text(text.to_string())); + continue; + } + let (name, bytes) = media_bytes(item, "insert.photoAlbum")?; + if bytes.len() > ALBUM_MAX_BYTES { + return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB"))); + } + let next = total.saturating_add(bytes.len()); + if next > ALBUM_MAX_TOTAL { + return Err(bad("insert.photoAlbum", "the album is larger than 64 MB")); + } + total = next; + let ct = content_type(&name, &bytes); + if !ct.starts_with("image/") { + return Err(bad("insert.photoAlbum", format!("{name} is not a picture"))); + } + if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { + return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read"))); + } + let (px_w, px_h) = image_px(&bytes); + items.push(AlbumItem::Picture(AlbumPic { + name, + bytes, + px_w, + px_h, + rot: album_rot(item)?, + brightness: album_unit(item, "brightness")?, + contrast: album_unit(item, "contrast")?, + })); + } + Ok(items) +} + +fn image_px(bytes: &[u8]) -> (f64, f64) { + image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .map(|(w, h)| (w as f64, h as f64)) + .filter(|(w, h)| *w > 0.0 && *h > 0.0) + .unwrap_or((4.0, 3.0)) +} + +fn file_stem(name: &str) -> String { + let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name); + let stem = match base.rsplit_once('.') { + Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s, + _ => base, + }; + if stem.is_empty() { "Picture".to_string() } else { stem.to_string() } +} + +fn album_plan(items: Vec, per: usize) -> Vec { + let per = per.max(1); + let mut out = Vec::new(); + let mut batch = Vec::new(); + for item in items { + match item { + AlbumItem::Text(text) => { + if !batch.is_empty() { + out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); + } + out.push(AlbumSlide::Text(text)); + } + AlbumItem::Picture(pic) => { + batch.push(pic); + if batch.len() >= per { + out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); + } + } + } + } + if !batch.is_empty() { + out.push(AlbumSlide::Pictures(batch)); + } + out +} + +struct Placed { + pic: [f64; 4], + caption: Option<[f64; 4]>, +} + +fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) { + let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 }; + let box_w = box_w.max(1.0); + let box_h = box_h.max(1.0); + if !aspect.is_finite() || aspect <= 0.0 { + return (box_w, box_h); + } + if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) } +} + +fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec) { + let margin = if layout.fit { 18.0 } else { 36.0 }; + let title_band = if layout.title { 48.0 } else { 0.0 }; + let gap = 12.0; + let cap_h = 22.0; + let cap_gap = 4.0; + let left = margin; + let top = margin + title_band; + let grid_w = (width - margin - left).max(1.0); + let grid_h = (height - margin - top).max(1.0); + let cols = layout.cols.max(1); + let rows = layout.rows.max(1); + let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0); + let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0); + let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h }; + let mut placed = Vec::with_capacity(n); + for i in 0..n { + let col = i % cols; + let row = i / cols; + if row >= rows { + break; + } + let cell_x = left + col as f64 * (cell_w + gap); + let cell_y = top + row as f64 * (cell_h + gap); + placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None }); + } + let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None }; + (title, placed) +} + +fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec) { + let (title, slots) = place_album(width, height, layout, pics.len(), captions); + let placed = pics + .iter() + .zip(slots) + .map(|(pic, slot)| { + let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h); + let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0; + let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0; + let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None }; + Placed { pic: [x, y, dw, dh], caption } + }) + .collect(); + (title, placed) +} + +fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> { + let params = match frame { + AlbumFrame::None => return Ok(()), + AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}), + AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}), + AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}), + }; + s.execute("shape.line", ¶ms)?; + Ok(()) +} + +struct AlbumMark { + id: u32, + layout: String, + frame: String, + captions: bool, + grayscale: bool, + theme: String, +} + +fn parse_album_mark(raw: &str) -> Option { + let rest = raw.strip_prefix("deckcraft-album:")?; + let (id_str, rest) = rest.split_once(';').unwrap_or((rest, "")); + let id = id_str.parse::().ok().filter(|id| *id != 0)?; + let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() }; + if !rest.is_empty() { + for part in rest.split(';') { + let Some((k, v)) = part.split_once('=') else { continue }; + match k { + "layout" => mark.layout = v.to_string(), + "frame" => mark.frame = v.to_string(), + "captions" => mark.captions = v == "1", + "gray" => mark.grayscale = v == "1", + "theme" => mark.theme = v.to_string(), + _ => {} + } + } + } + Some(mark) +} + +fn album_marker(id: u32, opts: &AlbumOpts) -> String { + let theme = opts.theme.as_deref().unwrap_or(""); + format!( + "deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}", + opts.layout_key, + opts.frame_key, + if opts.captions { 1 } else { 0 }, + if opts.grayscale { 1 } else { 0 }, + ) +} + +fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> { + let marker = album_marker(album_id, opts); + s.edit(|doc, sel| { + let Some(slide) = doc.slides.get_mut(sel.slide) else { + return Err(bad("insert.photoAlbum", "no slide to mark")); + }; + let slide = std::sync::Arc::make_mut(slide); + if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) { + slide.raw_ext = Some(marker); + } + slide.name = "Photo Album".into(); + Ok(()) + }) +} + +fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> { + let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) }; + let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default(); + if current.eq_ignore_ascii_case(name) { + return Ok(()); + } + s.execute("design.theme", &json!({"name": name}))?; + Ok(()) +} + +fn album_id_param(p: &Value) -> Option { + match p.get("album") { + Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0), + Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v != 0), + _ => None, + } +} + +fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec { + doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect() +} + +fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec { + if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") { + return Vec::new(); + } + let mut start = index; + while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") { + start -= 1; + } + let mut end = index; + while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") { + end += 1; + } + doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect() +} + +/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker). +fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec)> { + let st = s.doc()?; + if let Some(id) = album_id_param(p) { + let ids = slides_with_album(&st.doc, id); + if ids.is_empty() { + return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); + } + return Ok((id, ids)); + } + let Some(slide) = st.doc.slides.get(st.selection.slide) else { + return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); + }; + if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) { + let ids = slides_with_album(&st.doc, mark.id); + if ids.is_empty() { + return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); + } + return Ok((mark.id, ids)); + } + if slide.name == "Photo Album" { + let ids = album_name_run(&st.doc, st.selection.slide); + if !ids.is_empty() { + return Ok((0, ids)); + } + } + Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")) +} + +fn pic_count(slide: &deckcraft_model::Slide) -> usize { + slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count() +} + +fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) { + let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1); + let per = if max_pics >= 3 { + "4" + } else if max_pics == 2 { + "2" + } else { + "1" + }; + let title = + slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0))); + let layout = if title { format!("{per}title") } else { per.into() }; + let width = slides.iter().find_map(|s| { + s.shapes.iter().find_map(|sh| match &sh.kind { + ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width), + _ => None, + }) + }); + let frame = match width { + None => "none", + Some(w) if (w - 6.0).abs() < 0.2 => "simple", + Some(w) if (w - 2.25).abs() < 0.2 => "rounded", + Some(w) if w < 0.2 => "none", + Some(_) => "rect", + }; + let captions = slides.iter().any(|s| { + let bottoms: Vec = + s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect(); + !bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b))) + }); + let mut any = false; + let mut gray = true; + for s in slides { + for sh in &s.shapes { + if let ShapeKind::Picture { fill } = &sh.kind { + any = true; + gray &= fill.adjust.grayscale; + } + } + } + (layout, frame.into(), captions, any && gray, theme_now.to_string()) +} + +fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec { + let mut items = Vec::new(); + for slide in slides { + let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); + if pics.is_empty() { + let text = slide + .shapes + .iter() + .filter(|sh| sh.text_box) + .filter_map(|sh| sh.text.as_ref().map(|t| t.text())) + .find(|t| !t.trim().is_empty()) + .unwrap_or_default(); + if !text.trim().is_empty() { + items.push(json!({"text": text.trim()})); + } + continue; + } + for sh in pics { + let ShapeKind::Picture { fill } = &sh.kind else { continue }; + let media = doc.media(fill.media); + let name = if !sh.descr.is_empty() { + sh.descr.clone() + } else { + media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into()) + }; + items.push(json!({ + "name": name, + "data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(), + "rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0), + "brightness": fill.adjust.brightness, + "contrast": fill.adjust.contrast, + })); + } + } + items +} + +fn album_seed(s: &Session) -> Result { + let (id, ids) = album_target(s, &Value::Null)?; + let st = s.doc()?; + let slides: Vec<&deckcraft_model::Slide> = + ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect(); + let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into()); + let inferred = infer_album(&slides, &theme_now); + let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark)); + let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark { + ( + if mark.layout.is_empty() { inferred.0 } else { mark.layout }, + if mark.frame.is_empty() { inferred.1 } else { mark.frame }, + mark.captions, + mark.grayscale, + if mark.theme.is_empty() { theme_now } else { mark.theme }, + ) + } else { + inferred + }; + Ok(json!({ + "album": if id == 0 { Value::Null } else { json!(id) }, + "layout": layout, + "frame": frame, + "captions": captions, + "grayscale": grayscale, + "theme": theme, + "items": items_from_slides(&st.doc, &slides), + })) +} + +fn insert_album(s: &mut Session, items: Vec, opts: &AlbumOpts, at: Option, album_id: u32) -> Result { + let (width, height, first) = { + let st = s.doc()?; + let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) }); + (st.doc.slide_size.width, st.doc.slide_size.height, first) + }; + let layout = opts.layout; + let frame = opts.frame; + let captions = opts.captions; + let plan = album_plan(items, layout.per); + let mut slides = 0usize; + let mut pictures = 0usize; + let mut pin = at; + for slide in plan { + if let Some(index) = pin.take() { + s.execute("slide.new", &json!({"layout": "blank", "at": index}))?; + } else { + s.execute("slide.new", &json!({"layout": "blank"}))?; + } + stamp_album_slide(s, album_id, opts)?; + slides += 1; + match slide { + AlbumSlide::Text(text) => { + let rect = if layout.title { + [36.0, 18.0, (width - 72.0).max(36.0), 48.0] + } else { + [36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)] + }; + s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?; + } + AlbumSlide::Pictures(pics) => { + let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default(); + let (title, placed) = place_pictures(width, height, layout, &pics, captions); + for (pic, cell) in pics.iter().zip(placed) { + let data = base64_encode(&pic.bytes); + let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?; + let Some(id) = inserted.get("id").and_then(Value::as_u64) else { + return Err(bad("insert.photoAlbum", "the picture was not inserted")); + }; + apply_frame(s, frame, id)?; + if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 { + s.execute( + "picture.adjust", + &json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}), + )?; + } + if pic.rot > 0.01 { + s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?; + } + pictures += 1; + if let Some(cap) = cell.caption { + s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?; + } + } + if let Some(rect) = title { + s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?; + } + } + } + } + s.execute("slide.go", &json!({"index": first}))?; + Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id})) +} diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs new file mode 100644 index 000000000..41323d390 --- /dev/null +++ b/crates/engine/src/cmd/insert/chart.rs @@ -0,0 +1,218 @@ +use super::*; + +pub fn chart_type(name: &str) -> ChartType { + match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { + "bar" | "clusteredbar" => ChartType::Bar, + "stackedbar" => ChartType::StackedBar, + "percentbar" => ChartType::PercentBar, + "line" => ChartType::Line, + "linemarkers" | "linewithmarkers" => ChartType::LineMarkers, + "stackedline" => ChartType::StackedLine, + "area" => ChartType::Area, + "stackedarea" => ChartType::StackedArea, + "pie" => ChartType::Pie, + "doughnut" | "donut" => ChartType::Doughnut, + "scatter" | "xy" => ChartType::Scatter, + "bubble" => ChartType::Bubble, + "radar" => ChartType::Radar, + "filledradar" => ChartType::FilledRadar, + "stackedcolumn" => ChartType::StackedColumn, + "percentcolumn" => ChartType::PercentColumn, + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "histogram" => ChartType::Histogram, + "pareto" => ChartType::Pareto, + "treemap" => ChartType::Treemap, + "sunburst" => ChartType::Sunburst, + "boxwhisker" | "box" => ChartType::BoxWhisker, + "stock" => ChartType::Stock, + "surface" => ChartType::Surface, + "combo" => ChartType::Combo, + _ => ChartType::Column, + } +} + +pub(super) fn chart(s: &mut Session, p: &Value) -> Result { + let kind = chart_type(str_param(p, "type").unwrap_or("column")); + let mut c = Chart::sample(kind); + if let Some(cats) = p.get("categories").and_then(Value::as_array) { + c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect(); + } + if let Some(series) = p.get("series").and_then(Value::as_array) { + c.series = series + .iter() + .map(|sv| deckcraft_model::chart::Series { + name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), + values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), + ..Default::default() + }) + .collect(); + } + if let Some(t) = p.get("title") { + c.title = t.as_str().map(String::from); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); + let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; + Ok(json!({"id": id})) +} + +#[allow(dead_code)] +fn _kinds() -> RunKind { + RunKind::Text +} + +fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { + edit_shapes(s, p, cmd, |sh| match &mut sh.kind { + ShapeKind::Chart(c) => f(c), + _ => Err(bad(cmd, "not a chart")), + }) +} + +pub(super) fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { + let k = chart_type(str_param(p, "type").unwrap_or("column")); + with_chart(s, p, "chart.type", |c| { + c.kind = k; + c.vary_colors = + matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) + || c.series.len() == 1 && c.vary_colors; + if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + c.gridlines = true; + } + c.raw = None; + Ok(()) + }) +} + +pub(super) fn chart_data(s: &mut Session, p: &Value) -> Result { + let cats: Option> = p + .get("categories") + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect()); + let series: Option> = p.get("series").and_then(Value::as_array).map(|a| { + a.iter() + .map(|sv| deckcraft_model::chart::Series { + name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), + values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(), + ..Default::default() + }) + .collect() + }); + let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { + a.iter() + .take(deckcraft_model::chart::MAX_LEVELS) + .map(|l| { + l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() + }) + .collect() + }); + with_chart(s, p, "chart.data", |c| { + if let Some(cs) = &cats { + c.categories = cs.clone(); + } + if let Some(l) = &levels { + c.category_levels = l.clone(); + } + if let Some(se) = &series { + // Keep series formatting by position. + let old = std::mem::take(&mut c.series); + c.series = se + .iter() + .enumerate() + .map(|(i, n)| { + let mut x = old.get(i).cloned().unwrap_or_default(); + x.name = n.name.clone(); + x.values = n.values.clone(); + x + }) + .collect(); + } + c.raw = None; + Ok(()) + }) +} + +fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { + if let Some(n) = bin_count { + c.bin_count = n; + if n.is_some() { + c.bin_size = None; + } + } + if let Some(w) = bin_size { + c.bin_size = w; + if w.is_some() { + c.bin_count = None; + } + } +} + +fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { + if let Some(t) = subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); + t.sort_unstable(); + t.dedup(); + c.subtotals = t; + } + if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { + c.up_down_bars = b; + c.raw = None; + } + if let Some(q) = p.get("quartiles").and_then(Value::as_str) { + c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); + c.raw = None; + } +} + +pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result { + // Histogram bins: a count or a width (setting one clears the other; null for automatic). + let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); + let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); + if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { + return Err(bad("chart.options", "binSize must be a positive number")); + } + // Waterfall total points. + let subtotals: Option> = + p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); + // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's + // default (tx1 at lumMod 65 % / lumOff 35 %). + let text_color = match p.get("textColor") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => { + Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) + } + }; + with_chart(s, p, "chart.options", |c| { + if let Some(t) = &text_color { + c.text_color = t.clone(); + } + apply_bin_options(c, bin_count, bin_size); + apply_subtotals_and_bars(c, p, subtotals.as_deref()); + if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { + c.raw = None; + } + if let Some(t) = p.get("title") { + c.title = t.as_str().map(String::from); + } + if let Some(l) = p.get("legend") { + c.legend = l.as_str().map(String::from); + } + if let Some(v) = bool_param(p, "dataLabels") { + c.data_labels = v; + // A series' own setting would otherwise keep overriding the chart-wide switch. + for series in &mut c.series { + series.data_labels = None; + } + } + if let Some(v) = bool_param(p, "gridlines") { + c.gridlines = v; + } + if let Some(v) = str_param(p, "palette") { + c.palette = v.to_string(); + } + c.raw = None; + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs new file mode 100644 index 000000000..f0959688a --- /dev/null +++ b/crates/engine/src/cmd/insert/links.rs @@ -0,0 +1,116 @@ +use super::*; + +pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + // `display`: what a caret with nothing to link inserts, the address or the slide's title. + let (action, display) = if let Some(u) = str_param(p, "url") { + (Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string()) + } else if let Some(i) = usize_param(p, "slide") { + let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; + let title = slide.title(); + (Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title }) + } else { + return Err(bad("insert.hyperlink", "missing `url` or `slide`")); + }; + let display = str_param(p, "text").map_or(display, str::to_string); + let link = Hyperlink { + action, + tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), + highlight_click: bool_or(p, "highlightClick", false), + sound: None, + stop_sound: false, + zoom_return: None, + }; + if s.doc()?.selection.text.is_some() { + return crate::cmd::text::link_text(s, link, &display); + } + edit_shapes(s, p, "insert.hyperlink", |sh| { + sh.click = Some(link.clone()); + Ok(()) + }) +} + +/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does +/// in a show. +pub(super) fn shape_action(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + const CMD: &str = "shape.action"; + let hover = match str_param(p, "on").unwrap_or("click") { + "click" => false, + "hover" | "over" => true, + o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))), + }; + let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`"))); + let action = match str_param(p, "action").unwrap_or("none") { + "none" => None, + "next" => Some(Action::NextSlide), + "previous" => Some(Action::PreviousSlide), + "first" => Some(Action::FirstSlide), + "last" => Some(Action::LastSlide), + "lastViewed" => Some(Action::LastViewed), + "endShow" => Some(Action::EndShow), + "playMedia" => Some(Action::PlayMedia), + "url" => Some(Action::Url { url: need("url")? }), + "customShow" => { + let name = need("show")?; + if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { + return Err(bad(CMD, format!("no custom show `{name}`"))); + } + Some(Action::CustomShow { name, returns: true }) + } + "program" => Some(Action::Program { path: need("path")? }), + "slide" => { + let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?; + let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?; + Some(Action::Slide { slide: slide.id }) + } + a => return Err(bad(CMD, format!("unknown action `{a}`"))), + }; + let highlight = bool_param(p, "highlight").unwrap_or(true); + let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); + let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None }); + edit_shapes(s, p, CMD, |sh| { + if hover { + sh.hover = link.clone(); + } else { + sh.click = link.clone(); + } + Ok(()) + }) +} + +pub(super) fn action_button(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + let kind = str_param(p, "kind").unwrap_or("custom"); + let (preset, action) = match kind { + "back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)), + "forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)), + "beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)), + "end" => ("actionButtonEnd", Some(Action::LastSlide)), + "home" => ("actionButtonHome", Some(Action::FirstSlide)), + "information" => ("actionButtonInformation", None), + "return" => ("actionButtonReturn", Some(Action::LastViewed)), + "movie" => ("actionButtonMovie", None), + "document" => ("actionButtonDocument", None), + "sound" => ("actionButtonSound", None), + "help" => ("actionButtonHelp", None), + _ => ("actionButtonBlank", None), + }; + let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0)); + let shape = Shape { + xfrm: Some(rect), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), + click: action.map(|a| Hyperlink { + action: a, + tooltip: String::new(), + highlight_click: true, + sound: None, + stop_sound: false, + zoom_return: None, + }), + ..Default::default() + }; + let id = add_shape(s, "insert.actionButton", shape, true)?; + Ok(json!({"id": id})) +} diff --git a/crates/engine/src/cmd/insert/picture.rs b/crates/engine/src/cmd/insert/picture.rs new file mode 100644 index 000000000..74bd45da4 --- /dev/null +++ b/crates/engine/src/cmd/insert/picture.rs @@ -0,0 +1,243 @@ +use super::*; + +/// Read media bytes from `data` (base64) or `path`. +pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { + if let Some(d) = str_param(p, "data") { + let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes)); + } + if let Some(path) = str_param(p, "path") { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); + return Ok((name, bytes)); + } + Err(bad(cmd, "missing `path` or `data`")) +} + +pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { + let l = name.to_ascii_lowercase(); + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { + "image/png" + } else if bytes.starts_with(&[0xFF, 0xD8]) { + "image/jpeg" + } else if bytes.starts_with(b"GIF8") { + "image/gif" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { + "audio/wav" + } else if bytes.starts_with(b"BM") { + "image/bmp" + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + "image/tiff" + } else if bytes.starts_with(b"fLaC") { + "audio/flac" + } else if bytes.starts_with(b"OggS") { + if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } + } else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 { + // ADTS (layer bits 00): AAC. + "audio/aac" + } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { + "audio/mpeg" + } else if bytes.starts_with(b"FORM") { + "audio/aiff" + } else if bytes.get(4..8) == Some(b"ftyp") { + if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") { + "audio/mp4" + } else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") { + "video/quicktime" + } else { + "video/mp4" + } + } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { + if l.ends_with(".mka") || l.ends_with(".weba") { + "audio/webm" + } else if l.ends_with(".mkv") { + "video/x-matroska" + } else { + "video/webm" + } + } else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { + if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" } + } else if bytes.starts_with(b"caff") { + "audio/x-caf" + } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { + let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) + .unwrap_or((288.0, 216.0)); + let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); + (w * k, h * k) +} + +pub(super) fn picture(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = media_bytes(p, "insert.picture")?; + let ct = content_type(&name, &bytes); + if !ct.starts_with("image/") { + return Err(bad("insert.picture", format!("{name} is not a picture"))); + } + if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { + return Err(EngineError::Other(format!("{name}: the picture can't be read"))); + } + let (w, h) = fitted(s, &bytes); + // Into a selected empty picture/content placeholder, if there is one. + let target_ph = s.active().and_then(|d| { + d.selected_shapes() + .into_iter() + .find(|x| { + x.ph_type() + .is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body)) + && x.text.as_ref().is_none_or(|t| t.is_empty()) + }) + .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) + }); + let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { + Some((_, b)) => { + // Fill the placeholder, cropping the picture to its aspect. + b + } + None => default_rect(s, w, h), + }); + let crop = match (target_ph, rect_param(p, "rect")) { + (Some(_), None) => { + let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); + if ia > ba { + let c = (1.0 - ba / ia) / 2.0; + [c, 0.0, c, 0.0] + } else { + let c = (1.0 - ia / ba) / 2.0; + [0.0, c, 0.0, c] + } + } + _ => [0.0; 4], + }; + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?; + let shape = Shape { + xfrm: Some(rect), + kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, + descr: name.clone(), + ..Default::default() + }; + if let Some((ph_id, _)) = target_ph { + // Replace the placeholder with the picture (it keeps the placeholder link). + let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; + if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { + sh.kind = shape.kind.clone(); + sh.xfrm = Some(rect); + sh.ph = phv.map(|mut p| { + p.kind = deckcraft_model::PhType::Picture; + p + }); + sh.text = None; + sh.descr = name.clone(); + } + sel.shapes = vec![ph_id]; + Ok(()) + })?; + return Ok(json!({"id": ph_id})); + } + let id = add_shape(s, "insert.picture", shape, true)?; + Ok(json!({"id": id})) +} + +fn media(s: &mut Session, p: &Value, video: bool) -> Result { + let cmd = if video { "insert.video" } else { "insert.audio" }; + let (name, bytes) = media_bytes(p, cmd)?; + let ct = content_type(&name, &bytes); + let bytes = std::sync::Arc::new(bytes); + let info = deckcraft_media::probe(&bytes); + let has_video = info.as_ref().is_ok_and(|i| i.video.is_some()); + let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some()); + match &info { + Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))), + Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))), + // Not something we can read: embed it anyway when the type says it's media (it still + // round-trips through PPTX), otherwise refuse. + Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))), + Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))), + _ => {} + } + let (duration_ms, width, height, playable) = match &info { + Ok(i) => { + let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)); + (i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable()) + } + Err(_) => (0, 0, 0, false), + }; + let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); + let warning = if !playable { + Some(match &info { + Ok(i) => { + let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); + format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) + } + Err(e) => format!("{name} was inserted, but it can't be played ({e})"), + }) + } else if video && !decodable_video { + // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame + // can ever show — say so instead of leaving an unexplained black box (#1237). + info.as_ref() + .ok() + .and_then(|i| i.video.as_ref()) + .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) + } else { + None + }; + let poster_png = if video && decodable_video { crate::cmd::media::poster_png(&bytes, 0) } else { None }; + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or_else(|| { + if video { + // Natural size (96 dpi), fitted inside 90 % of the slide and centred. + let (w, h) = + if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) }; + let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0); + let (w, h) = (w * k, h * k); + Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) + } else { + Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) + } + }); + let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); + let (media, poster) = s.edit(|doc, _| { + let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?; + let poster = match poster_png { + Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?), + None => None, + }; + Ok((media, poster)) + })?; + let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; + let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; + if let Some(w) = &warning { + s.ui_requests.push(crate::UiRequest::Message { text: w.clone() }); + } + let mut r = + json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning}); + if let Ok(i) = &info { + r["probe"] = crate::cmd::media::probe_json(i); + } + Ok(r) +} + +pub(super) fn audio(s: &mut Session, p: &Value) -> Result { + media(s, p, false) +} + +pub(super) fn video(s: &mut Session, p: &Value) -> Result { + media(s, p, true) +} diff --git a/crates/engine/src/cmd/insert/text_fields.rs b/crates/engine/src/cmd/insert/text_fields.rs new file mode 100644 index 000000000..d19f1dd1a --- /dev/null +++ b/crates/engine/src/cmd/insert/text_fields.rs @@ -0,0 +1,243 @@ +use super::*; + +pub(super) fn word_art(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").unwrap_or("Your text here"); + let size = s.doc()?.doc.slide_size; + let style = usize_param(p, "style").unwrap_or(0); + let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text); + if let Some(t) = shape.text.as_mut() { + for para in &mut t.paragraphs { + para.props.align = Some(deckcraft_model::text::Align::Center); + for r in &mut para.runs { + r.props.size = Some(54.0); + r.props.bold = Some(style % 2 == 1); + r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { + 0 => SchemeSlot::Tx1, + 1 => SchemeSlot::Accent1, + 2 => SchemeSlot::Accent2, + _ => SchemeSlot::Accent4, + }))); + if style % 3 == 2 { + r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); + } + } + } + } + let id = add_shape(s, "insert.wordArt", shape, true)?; + crate::cmd::text::fit_text_box(s, id)?; + Ok(json!({"id": id})) +} + +fn field_run(s: &mut Session, field: &str, text: &str) -> Result { + if s.doc()?.selection.text.is_some() { + return crate::cmd::text::insert_field(s, field, text); + } + // Not editing: turn on the footer field for the slide (Header & Footer). + let key = if field == "slidenum" { "slideNumber" } else { "date" }; + s.execute("design.headerFooter", &json!({key: true})) +} + +pub(super) fn slide_number(s: &mut Session, _p: &Value) -> Result { + field_run(s, "slidenum", "‹#›") +} + +const DATE_FORMATS: [&str; 13] = [ + "datetime1", + "datetime2", + "datetime3", + "datetime4", + "datetime5", + "datetime6", + "datetime7", + "datetime8", + "datetime9", + "datetime10", + "datetime11", + "datetime12", + "datetime13", +]; + +struct DateOpts { + format: String, + update: bool, + /// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language. + lang: Option, + sample: String, +} + +/// `true` when the call carries a format, update flag or language (apply it). Empty params open +/// the dialog instead. +fn date_time_apply(p: &Value) -> bool { + p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language")) +} + +fn canon_lang(raw: &str) -> Option<&'static str> { + match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "en" | "en-us" => Some("en-US"), + "en-gb" => Some("en-GB"), + "fr" | "fr-fr" => Some("fr-FR"), + "ja" | "ja-jp" => Some("ja-JP"), + "sv" | "sv-se" => Some("sv-SE"), + _ => None, + } +} + +fn date_opts(p: &Value) -> Result { + if p.get("format").is_some() { + let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f)); + if f.is_none() { + return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13")); + } + } + if p.get("update").is_some() && bool_param(p, "update").is_none() { + return Err(bad("insert.dateTime", "`update` must be true or false")); + } + let lang = if p.get("language").is_some() { + let raw = str_param(p, "language").filter(|l| !l.trim().is_empty()); + let Some(raw) = raw else { + return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE")); + }; + Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string()) + } else { + None + }; + let format = str_param(p, "format").unwrap_or("datetime1").to_string(); + let sample = date_sample(&format, lang.as_deref())?; + Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample }) +} + +/// The text `format` shows now in `lang`. Errors when that language has no names for the format +/// (month names and AM/PM are English only). An empty string where there is no clock (wasm). +fn date_sample(format: &str, lang: Option<&str>) -> Result { + use deckcraft_text::datetime::{DateTime, field_text}; + let probe = DateTime::new(2026, 10, 9, 20, 5, 7); + if field_text(format, lang, probe).is_none() { + return Err(bad("insert.dateTime", "this format isn't available in that language")); + } + Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default()) +} + +pub(super) fn date_time(s: &mut Session, p: &Value) -> Result { + if !date_time_apply(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) }); + return ok(); + } + let opts = date_opts(p)?; + let (doc, sel) = { + let st = s.doc()?; + (st.doc.clone(), st.selection.clone()) + }; + let r = apply_date(s, &opts); + if r.is_err() + && let Some(st) = s.active_mut() + { + st.doc = doc; + st.selection = sel; + } + r +} + +fn apply_date(s: &mut Session, o: &DateOpts) -> Result { + if s.doc()?.selection.text.is_some() { + if o.update { + crate::cmd::text::insert_field(s, &o.format, &o.sample)?; + if let Some(lang) = &o.lang { + stamp_caret_lang(s, lang)?; + } + } else { + insert_plain(s, &o.sample, o.lang.as_deref())?; + } + return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); + } + // Not editing: the slide's date placeholder (this slide only). A layout with no date + // placeholder gets a text box instead, so the chosen format is still inserted. + s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; + if !write_date_placeholder(s, o)? { + return insert_date_box(s, o); + } + Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) +} + +/// Point the current slide's date placeholder at this format (a field, or the sample as plain text). +fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { + let format = o.format.clone(); + let sample = o.sample.clone(); + let lang = o.lang.clone(); + let update = o.update; + s.edit(|doc, sel| { + let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) }; + let sl = std::sync::Arc::make_mut(sl); + let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else { + return Ok(false); + }; + let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + if let Some(lang) = lang { + props.lang = Some(lang); + } + let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; + let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() }; + let body = sh.text.clone().unwrap_or_default(); + sh.text = Some(TextBody { paragraphs: vec![para], ..body }); + Ok(true) + }) +} + +fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result { + let size = s.doc()?.doc.slide_size; + let y = (size.height - 48.0).max(0.0); + s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?; + if o.update { + crate::cmd::text::insert_field(s, &o.format, &o.sample)?; + } else { + crate::cmd::text::insert_text(s, &o.sample, None)?; + } + if let Some(lang) = &o.lang { + stamp_caret_lang(s, lang)?; + } + Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample})) +} + +/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge, +/// so the date stays its own run next to the surrounding text. +fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> { + let text = text.to_string(); + let lang = lang.map(str::to_string); + crate::cmd::text::edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = deckcraft_model::edit::delete(body, a, b); + let mut props = deckcraft_model::edit::props_at(body, at); + if let Some(lang) = lang.clone() { + props.lang = Some(lang); + } + let c = deckcraft_model::edit::insert(body, at, &text, Some(props)); + t.anchor = c; + t.caret = c; + })?; + Ok(()) +} + +/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates. +fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> { + let lang = lang.to_string(); + crate::cmd::text::edit_text(s, move |body, t| { + let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return }; + let mut at = 0usize; + for r in &mut p.runs { + let n = r.char_len(); + if n > 0 && at < t.caret.1 && t.caret.1 <= at + n { + r.props.lang = Some(lang); + return; + } + at += n; + } + })?; + Ok(()) +} + +pub(super) fn symbol(s: &mut Session, p: &Value) -> Result { + let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string(); + if s.doc()?.selection.text.is_none() { + return Err(bad("insert.symbol", "place the insertion point in text first")); + } + crate::cmd::text::insert_text(s, &t, None) +} From d7c06b53eb67b9c12e6276b8fa0f228c68f43a46 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:59 +0000 Subject: [PATCH 51/90] refactor(engine): split cmd/smartart.rs into cmd/smartart/{nodes,paint,layout,diagrams,edit}.rs --- crates/engine/src/cmd/smartart.rs | 1789 +------------------- crates/engine/src/cmd/smartart/diagrams.rs | 254 +++ crates/engine/src/cmd/smartart/edit.rs | 375 ++++ crates/engine/src/cmd/smartart/layout.rs | 752 ++++++++ crates/engine/src/cmd/smartart/nodes.rs | 146 ++ crates/engine/src/cmd/smartart/paint.rs | 263 +++ 6 files changed, 1805 insertions(+), 1774 deletions(-) create mode 100644 crates/engine/src/cmd/smartart/diagrams.rs create mode 100644 crates/engine/src/cmd/smartart/edit.rs create mode 100644 crates/engine/src/cmd/smartart/layout.rs create mode 100644 crates/engine/src/cmd/smartart/nodes.rs create mode 100644 crates/engine/src/cmd/smartart/paint.rs diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 30e38aa6d..3dbcf40f9 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -17,6 +17,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, current_shapes, has_selection, shape_of, str_param, targets, xfrm_of}; use crate::{EngineError, Result, Session}; +mod diagrams; +mod edit; +mod layout; +mod nodes; +mod paint; + +use edit::{ + add_node, change_colors, change_layout, change_style, demote, from_text, nodes, promote, remove_node, set_nodes, set_picture, to_shapes, to_text, +}; +pub use edit::{insert, selected}; +pub use nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove}; +pub use paint::{BRICK_CAMERA, is_3d, layout}; +#[cfg(test)] +use paint::{MIN_SCALE, accent, layout_items, shade}; + /// Deepest outline level the Text Pane allows. pub const MAX_LEVEL: u8 = 8; /// Most top-level shapes a layout draws. @@ -264,1780 +279,6 @@ pub fn specs() -> Vec { ] } -/// Nodes from text lines: each leading tab is one outline level. -pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { - lines - .into_iter() - .filter(|l| !l.trim().is_empty()) - .map(|l| { - let tabs = l.chars().take_while(|c| *c == '\t').count(); - DiagramNode { - text: l.trim().to_string(), - level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), - picture: None, - assistant: false, - title: String::new(), - } - }) - .collect() -} - -/// Levels made consistent: the first node is top level and no node is more than one level -/// deeper than the node before it. -pub fn normalize(nodes: &mut [DiagramNode]) { - let mut prev: Option = None; - for n in nodes.iter_mut() { - n.level = match prev { - None => 0, - Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), - }; - prev = Some(n.level); - } -} - -/// Index one past the last descendant of node `i`. -fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { - let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); - let mut j = i + 1; - while nodes.get(j).is_some_and(|n| n.level > lv) { - j += 1; - } - j -} - -/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the -/// new node's index. -pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { - let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() }); - return nodes.len() - 1; - }; - let lv = nodes[i].level; - let (at, level) = match position { - "before" => (i, lv), - "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), - "above" => { - let end = subtree_end(nodes, i); - for n in &mut nodes[i..end] { - n.level = (n.level + 1).min(MAX_LEVEL); - } - (i, lv) - } - "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), - _ => (subtree_end(nodes, i), lv), - }; - nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() }); - at -} - -/// Remove node `i`; its sub-points move up one level. -pub fn remove(nodes: &mut Vec, i: usize) -> bool { - if i >= nodes.len() { - return false; - } - let end = subtree_end(nodes, i); - for n in &mut nodes[i + 1..end] { - n.level = n.level.saturating_sub(1); - } - nodes.remove(i); - normalize(nodes); - true -} - -/// Move node `i` and its sub-points one level up. False when it is already top level. -pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { - if nodes.get(i).is_none_or(|n| n.level == 0) { - return false; - } - let end = subtree_end(nodes, i); - for n in &mut nodes[i..end] { - n.level -= 1; - } - true -} - -/// Move node `i` and its sub-points one level down, under the node before it. False when there is -/// nothing to go under. -pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { - let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; - if cur.level > prev.level || cur.level >= MAX_LEVEL { - return false; - } - let end = subtree_end(nodes, i); - if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { - return false; - } - for n in &mut nodes[i..end] { - n.level += 1; - } - true -} - -/// A generated shape and the nodes whose text it shows (none for arrows and lines). -type Item = (Shape, Vec); - -/// Top-level nodes, each with its sub-points. -fn groups(d: &Diagram) -> Vec> { - let mut out: Vec> = vec![]; - for (i, n) in d.nodes.iter().enumerate() { - match out.last_mut() { - Some(g) if n.level > 0 => g.push(i), - _ => out.push(vec![i]), - } - } - out.truncate(MAX_SHAPES); - out -} - -/// Read text typed into the diagram's shapes back into its nodes. -fn sync_text(d: &mut Diagram, children: &[Shape]) { - if d.imported { - return; - } - let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); - for (shape, idx) in children.iter().zip(map) { - let Some(body) = &shape.text else { continue }; - if idx.is_empty() || body.paragraphs.len() != idx.len() { - continue; - } - for (p, i) in body.paragraphs.iter().zip(idx) { - if let Some(n) = d.nodes.get_mut(i) { - n.text = p.text(); - } - } - } -} - -fn slot(i: usize) -> SchemeSlot { - [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] -} - -/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` -/// keeps one. -fn accent(d: &Diagram, i: usize) -> SchemeSlot { - let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); - if let Some(r) = d.colors.strip_prefix("range") - && let Some(a @ 1..=6) = digit(r, 0) - { - // Colorful Range: alternate between two neighbouring accents. - return slot(a - 1 + i % 2); - } - match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { - Some(n @ 1..=6) => slot(n - 1), - _ => slot(i), - } -} - -/// Gradient Range: each shape a step darker than the one before (the steps cycle). -fn shade(d: &Diagram, i: usize) -> Option { - d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) -} - -/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) -/// lit by the three-point rig. -fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { - format!( - "", - (w * 12700.0).round() as i64, - (h * 12700.0).round() as i64 - ) -} - -/// Brick Scene's camera: the diagram's shapes seen from above and to the side. -pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; - -/// Whether `style` is one of the 3-D SmartArt Styles. -pub fn is_3d(style: &str) -> bool { - matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") -} - -/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet -/// boxes of the bullet lists) are a pale tint of the accent with dark text. -fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { - let a = accent(d, i); - let base = match shade(d, i) { - Some(t) => ColorRef::scheme(a).with(t), - None => ColorRef::scheme(a), - }; - let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); - let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); - sh.style = Some(ShapeStyle::accent(a)); - match d.style.as_str() { - _ if light => { - sh.fill = Some(Fill::solid(tint(80))); - sh.line = Some(Line::solid(tint(60), 1.0)); - } - "outline" => { - sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); - sh.line = Some(Line::solid(base.clone(), 2.25)); - } - "subtle" => { - sh.fill = Some(Fill::solid(base.clone())); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); - sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); - } - "polished" | "inset" | "cartoon" | "powder" | "brick" => { - let (bevel, bw, line, fill) = match d.style.as_str() { - "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), - "inset" => ("relaxedInset", 10.0, None, base.clone()), - "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), - "powder" => ("convex", 14.0, None, tint(40)), - _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), - }; - sh.fill = Some(Fill::solid(fill)); - sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); - sh.effects = Some(Effects { - outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), - bevel: Some(bevel.into()), - raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), - ..Default::default() - }); - } - "intense" => { - sh.fill = Some(Fill::Gradient(Gradient { - stops: vec![ - GradientStop { pos: 0.0, color: tint(30) }, - GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, - ], - shape: GradientShape::Linear { angle: 90.0, scaled: false }, - rotate_with_shape: true, - tile: [0.0; 4], - })); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); - sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); - } - _ => { - sh.fill = Some(Fill::solid(base.clone())); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); - } - } - if let Some(body) = &mut sh.text { - let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; - for p in &mut body.paragraphs { - for r in &mut p.runs { - r.props.fill = Some(Fill::solid(c.clone())); - } - } - } -} - -fn shadow(blur: f64, dist: f64) -> Shadow { - Shadow { - color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), - blur, - dist, - dir: 90.0, - inner: false, - sx: 1.0, - sy: 1.0, - kx: 0.0, - ky: 0.0, - align: String::new(), - rotate_with_shape: false, - } -} - -/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With -/// `title` false every line is a bullet (the bullet boxes of the bullet lists). -fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { - let mut body = TextBody::default(); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); - let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); - for (k, n) in lines.iter().enumerate() { - let mut para = Paragraph::new(&n.text); - let head = title && k == 0; - if head { - para.props.align = Some(Align::Center); - } else { - para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); - para.props.align = Some(Align::Left); - para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); - para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); - para.props.indent = Some(-10.0); - } - let size = if head { 20.0 } else { 14.0 }; - for r in &mut para.runs { - r.props.size = Some(size); - } - para.end_props.size = Some(size); - body.paragraphs.push(para); - } - if body.paragraphs.is_empty() { - body.paragraphs.push(Paragraph::new("")); - } - if !title { - body.body.anchor = Some(deckcraft_model::text::Anchor::Top); - } - Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } -} - -/// A plain shape with no text (arrows between steps). -fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { - let mut x = x; - x.rot = rot; - Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } -} - -fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { - let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); - x.flip_h = (b.0 < a.0) != (b.1 < a.1); - Shape { - name: "SmartArt connector".into(), - xfrm: Some(x), - geom: Geom::preset("line"), - fill: Some(Fill::None), - line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), - ..Default::default() - } -} - -/// The diagram's shapes laid out in `area` (ids not yet assigned). -pub fn layout(d: &Diagram, area: Xfrm) -> Vec { - layout_items(d, area).into_iter().map(|(s, _)| s).collect() -} - -/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is -/// shrunk per shape so its longest word fits on a line. -fn layout_items(d: &Diagram, area: Xfrm) -> Vec { - let mut v = layout_raw(d, area); - for (sh, _) in &mut v { - fit_words(sh); - } - v -} - -/// Rough advance of a character as a fraction of the font size (a sans text face). -const CHAR_W: f64 = 0.6; -/// Smallest font scale shrinking to fit goes to. -const MIN_SCALE: f64 = 0.25; - -/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so -/// no word is wider than its line and would break mid-word. Overflow in height is left to the -/// body's shrink-on-overflow autofit. -fn fit_words(sh: &mut Shape) { - let Some(w) = sh.xfrm.map(|x| x.w) else { return }; - // Round shapes keep their text in the inscribed rectangle (about 71% of the width). - let text_w = match sh.geom.preset_name() { - Some("ellipse" | "gear6" | "gear9") => w * 0.71, - _ => w, - }; - let Some(body) = &mut sh.text else { return }; - let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); - let mut scale: f64 = 1.0; - for p in &body.paragraphs { - let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); - for r in &p.runs { - let size = r.props.size.unwrap_or(18.0); - let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; - let need = word * size * CHAR_W; - if need > 0.0 { - scale = scale.min(line.max(0.0) / need); - } - } - } - let scale = scale.max(MIN_SCALE); - if scale >= 0.999 { - return; - } - let shrink = |v: &mut Option| { - if let Some(x) = v { - *x = (*x * scale * 2.0).floor() / 2.0; - } - }; - for p in &mut body.paragraphs { - for r in &mut p.runs { - shrink(&mut r.props.size); - } - shrink(&mut p.end_props.size); - shrink(&mut p.props.margin_left); - if let Some(i) = &mut p.props.indent { - *i *= scale; - } - } -} - -/// A picture placeholder for node `node`: its picture when it has one, else a pale box. -fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { - let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; - paint(d, &mut sh, i, true); - if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { - sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); - } - sh -} - -fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { - let gs = groups(d); - let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; - let n = gs.len().max(1); - let nf = n as f64; - let mut out: Vec = vec![]; - // A titled shape for top-level group `i`, painted in its accent. - let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { - let mut sh = text_shape(preset, x, &nodes(g), true); - paint(d, &mut sh, i, false); - out.push((sh, g.to_vec())); - }; - let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { - let mut sh = deco("rightArrow", x, rot); - paint(d, &mut sh, i, true); - out.push((sh, vec![])); - }; - match d.kind.as_str() { - "hierarchy" => return hierarchy(d, area, false), - "horizontalHierarchy" => return hierarchy(d, area, true), - "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), - "list" | "hBulletList" => { - // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. - let vertical = d.kind == "list"; - for (i, g) in gs.iter().enumerate() { - let (cell, bar_h) = if vertical { - ( - Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), - (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), - ) - } else { - let w = area.w / nf; - (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) - }; - let has_sub = g.len() > 1; - let bar_h = bar_h.min(cell.h); - titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); - if has_sub { - let pad = cell.w * 0.03; - let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); - let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - out.push((sh, g[1..].to_vec())); - } - } - } - "blockList" => { - // Basic Block List: equal blocks in rows, as square as the area allows. - let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); - let rows = n.div_ceil(cols); - let (w, h) = (area.w / cols as f64, area.h / rows as f64); - let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let in_row = if r + 1 == rows { n - r * cols } else { cols }; - let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; - titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); - } - } - "process" | "chevron" => { - let chevron = d.kind == "chevron"; - let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; - let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); - let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); - let y = area.y + (area.h - h) / 2.0; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * (w + gap); - titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); - if !chevron && i + 1 < n { - let aw = gap * 0.7; - arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); - } - } - } - "verticalProcess" => { - let gap = area.h * 0.06; - let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); - let w = area.w * 0.6; - let x = area.x + (area.w - w) / 2.0; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * (h + gap); - titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); - if i + 1 < n { - let s = gap * 0.8; - // A right arrow turned to point down. - arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); - } - } - } - "stepUp" => { - // Step Up Process: each step higher than the one before. - let w = area.w / nf; - let h = area.h / (nf + 1.0) * 1.6; - for (i, g) in gs.iter().enumerate() { - let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); - titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); - } - } - "cycle" | "blockCycle" => { - let block = d.kind == "blockCycle"; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; - titled( - &mut out, - if block { "roundRect" } else { "ellipse" }, - Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), - i, - g, - ); - if n > 1 { - // An arrow halfway to the next node, along the circle. - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.35).max(6.0); - let rot = (i as f64 + 0.5) / nf * 360.0; - arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); - } - } - } - "radial" => { - // Basic Radial: the first node in the middle, the others around it with spokes. - let Some((centre, rest)) = gs.split_first() else { return out }; - let m = rest.len().max(1) as f64; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dc = r * 0.75; - let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); - for (k, _) in rest.iter().enumerate() { - let a = (k as f64 / m * 360.0 - 90.0).to_radians(); - let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); - out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); - } - titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); - for (k, g) in rest.iter().enumerate() { - let a = (k as f64 / m * 360.0 - 90.0).to_radians(); - titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); - } - } - "pyramid" | "invertedPyramid" => { - // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as - // its own polygon so the sides keep the triangle's slope and the text stays upright. - let inverted = d.kind == "invertedPyramid"; - let h = area.h / nf; - let pw = area.h.min(area.w) * 1.15; - let px = area.x + (area.w - pw) / 2.0; - for (i, g) in gs.iter().enumerate() { - let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); - let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; - let (wt, wb) = (pw * t, pw * bt); - let w = wt.max(wb).max(1.0); - let bh = h * 0.96; - let x = px + (pw - w) / 2.0; - let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); - let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); - let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], - }; - // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. - // The tip band is a triangle: its text sits at the wide end, inset to the width there. - let tip = wt.min(wb) < 1e-6; - let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; - if let Some(b) = &mut sh.text { - b.body.inset_l = Some(inset); - b.body.inset_r = Some(inset); - if tip { - b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); - } - } - paint(d, &mut sh, i, false); - out.push((sh, g.clone())); - } - } - "matrix" | "titledMatrix" => { - let titled_m = d.kind == "titledMatrix"; - let s = area.h.min(area.w); - let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); - let (quads, title): (&[Vec], Option<&Vec>) = - if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; - for (i, g) in quads.iter().take(4).enumerate() { - let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); - titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); - } - if let Some(t) = title { - let c = s * 0.42; - let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); - paint(d, &mut sh, 0, true); - out.push((sh, t.clone())); - } - } - "pictureList" => { - // Vertical Picture List: a picture square at the left of each row, the text beside it. - let h = area.h / nf; - let bh = h * 0.88; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * h; - let s = bh.min(area.w * 0.3); - out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); - titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); - } - } - "pictureCaption" | "pictureProcess" => { - // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one - // row, arrows between the steps. - let process = d.kind == "pictureProcess"; - let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; - let rows = n.div_ceil(cols); - let gap = if process { area.w * 0.05 } else { 0.0 }; - let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; - let h = area.h / rows as f64; - let bw = if process { w } else { w * 0.9 }; - let ch = (h * 0.9).min(bw * 1.1); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let in_row = if r + 1 == rows { n - r * cols } else { cols }; - let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; - let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; - let y = area.y + r as f64 * h + (h - ch) / 2.0; - let ph = ch * 0.62; - out.push(( - picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), - vec![], - )); - titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); - if process && i + 1 < n { - let a = (gap * 0.7).min(ch * 0.2); - arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); - } - } - } - "textCycle" | "continuousCycle" => { - // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on - // a ring. - let text_only = d.kind == "textCycle"; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); - if !text_only { - let ring = r * 2.0 + dd * 0.3; - let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); - sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - } - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - let (w, h) = (dd * 1.35, dd * 0.7); - let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); - paint(d, &mut sh, i, false); - if text_only { - sh.fill = Some(Fill::None); - sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - sh.effects = None; - let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); - if let Some(b) = &mut sh.text { - for p in &mut b.paragraphs { - for r in &mut p.runs { - r.props.fill = Some(Fill::solid(c.clone())); - } - } - } - if n > 1 { - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.3).max(6.0); - arrow( - &mut out, - Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), - (i as f64 + 0.5) / nf * 360.0, - i, - ); - } - } - out.push((sh, g.clone())); - } - } - "segmentedCycle" | "pie" => { - // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. - // The text sits in a clear box over each segment's middle. - let ring = d.kind == "segmentedCycle"; - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let gap = if ring { 2.0 } else { 0.0 }; - for (i, g) in gs.iter().enumerate() { - // Angles clockwise from 3 o'clock, starting at the top. - let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; - let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; - let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); - let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); - // blockArc runs from its first angle to its second clockwise; so does pie. - sh.geom = Geom::Preset { - name: if ring { "blockArc" } else { "pie" }.into(), - adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, - }; - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - let m = ((a0 + a1) / 2.0).to_radians(); - let rr = if ring { s * 0.4375 } else { s * 0.3 }; - let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); - let th = s * 0.16; - let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - "multidirectionalCycle" => { - // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); - if n > 1 { - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.5).max(8.0); - let mut sh = deco( - "leftRightArrow", - Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), - (i as f64 + 0.5) / nf * 360.0, - ); - paint(d, &mut sh, i, true); - out.push((sh, vec![])); - } - } - } - "cycleMatrix" => { - // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each - // quarter's sub-points in a box at its corner. - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let c = s * 0.56; - for (i, g) in gs.iter().take(4).enumerate() { - let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); - let b = s * 0.4; - let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); - if g.len() > 1 { - let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - if let Some(body) = &mut sh.text { - // Text in the box's outer part, clear of the circle. - body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); - body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); - body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); - } - out.push((sh, g[1..].to_vec())); - } - } - for (i, g) in gs.iter().take(4).enumerate() { - let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); - let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; - let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); - sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - let q = c * 0.3; - let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g[..1].to_vec())); - } - } - "segmentedPyramid" => { - // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, - // every other one upside down. - let rows = (nf.sqrt().ceil() as usize).max(1); - let s = area.h.min(area.w / 1.15); - let pw = s * 1.15; - let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); - let (tw, th) = (pw / rows as f64, s / rows as f64); - let mut k = 0; - 'rows: for r in 0..rows { - let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; - for c in 0..(2 * r + 1) { - let Some(g) = gs.get(k) else { break 'rows }; - let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); - let mut sh = text_shape("rect", x, &nodes(g), true); - // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. - let path = if c % 2 == 1 { - format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) - } else { - format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) - }; - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { - w: tw, - h: th, - d: path, - fill: deckcraft_geom::preset::FillMode::Norm, - stroke: true, - }], - }; - if let Some(b) = &mut sh.text { - // The text in the triangle's wide half. - b.body.inset_l = Some(tw * 0.22); - b.body.inset_r = Some(tw * 0.22); - if c % 2 == 1 { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - b.body.inset_b = Some(th * 0.35); - } else { - b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); - b.body.inset_t = Some(th * 0.35); - } - } - paint(d, &mut sh, k, false); - out.push((sh, g.clone())); - k += 1; - } - } - } - "verticalBoxList" => { - // Vertical Box List: a pale box per node with its sub-points, its title on a tab - // overlapping the box's top left. - let h = area.h / nf; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * h; - let tab_h = (h * 0.3).min(40.0); - let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false); - paint(d, &mut bx, i, true); - if let Some(b) = &mut bx.text { - b.body.inset_t = Some(tab_h * 0.6); - } - out.push((bx, g[1..].to_vec())); - titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); - } - } - "trapezoidList" | "tableList" => { - // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the - // first node as a header bar over a column per other node. - let table = d.kind == "tableList"; - let (head, cols): (Option<&Vec>, &[Vec]) = - if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) }; - let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; - if let Some(h) = head { - titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); - } - let m = cols.len().max(1) as f64; - let w = area.w / m; - for (k, g) in cols.iter().enumerate() { - let i = k + usize::from(table); - let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); - let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true); - if let Some(b) = &mut sh.text { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - if !table { - b.body.inset_l = Some(x.w * 0.2); - b.body.inset_r = Some(x.w * 0.2); - b.body.inset_t = Some(x.h * 0.08); - } - } - paint(d, &mut sh, i, table); - out.push((sh, g.clone())); - } - } - "accentProcess" => { - // Accent Process: a title box per step with its sub-points in a pale box below and to the - // right, arrows between the titles. - let gap = area.w * 0.05; - let w = (area.w - gap * (nf - 1.0)) / nf; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * (w + gap); - let th = area.h * 0.22; - if g.len() > 1 { - let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - if let Some(b) = &mut sh.text { - b.body.inset_t = Some(th * 0.45); - } - out.push((sh, g[1..].to_vec())); - } - titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); - if i + 1 < n { - let a = gap * 0.8; - arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); - } - } - } - "continuousBlockProcess" => { - // Continuous Block Process: one big pale arrow behind a row of boxes. - let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); - paint(d, &mut big, 0, true); - out.push((big, vec![])); - let w = area.w * 0.85 / nf; - let h = area.h * 0.32; - for (i, g) in gs.iter().enumerate() { - let x = area.x + area.w * 0.02 + i as f64 * w; - titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); - } - } - "bendingProcess" => { - // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. - let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); - let rows = n.div_ceil(cols); - let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); - let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let x = area.x + c as f64 * cw; - let y = area.y + r as f64 * rh + (rh - bh) / 2.0; - titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g); - if i + 1 < n { - let a = (cw - bw) * 0.7; - if c + 1 < cols { - arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); - } else { - // The bend: down from the row's last box towards the next row's first. - let a = (rh - bh).min(a * 1.5).max(6.0); - arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); - } - } - } - } - "linearVenn" | "stackedVenn" => { - // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, - // the text near each one's top. - let linear = d.kind == "linearVenn"; - if linear { - let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); - let step = dc * 0.75; - let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; - for (i, g) in gs.iter().enumerate() { - let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true); - paint(d, &mut sh, i, false); - if let Some(Fill::Solid { color: c }) = &mut sh.fill { - *c = c.clone().with(ColorTransform::Alpha(55_000)); - } - out.push((sh, g.clone())); - } - } else { - let s = area.h.min(area.w); - let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); - for (i, _) in gs.iter().enumerate() { - let dc = s * (1.0 - i as f64 / nf); - let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - // Each circle's text in the band between its top and the next circle's. - let band = s / nf; - for (i, g) in gs.iter().enumerate() { - let dc = s * (1.0 - i as f64 / nf); - let top = bottom - dc; - let h = if i + 1 < n { band } else { dc }; - let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; - let mut t = - text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - } - "nestedTarget" => { - // Nested Target: rounded boxes nested inside each other, the text on each one's top band. - let band_x = area.w / 2.0 / nf * 0.6; - let band_y = area.h / nf * 0.9 / 2.0; - for (i, _) in gs.iter().enumerate() { - let (dx, dy) = (band_x * i as f64, band_y * i as f64); - let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - for (i, g) in gs.iter().enumerate() { - let (dx, dy) = (band_x * i as f64, band_y * i as f64); - let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; - let mut t = text_shape( - "rect", - Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), - &nodes(g), - true, - ); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - "gridMatrix" => { - // Grid Matrix: four quadrants on crossed axes. - let s = area.h.min(area.w); - let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); - let t = s * 0.06; - for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] - { - let mut sh = deco(preset, x, 0.0); - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - } - let q = s / 2.0 - t * 1.2; - for (i, g) in gs.iter().take(4).enumerate() { - let (c, r) = ((i % 2) as f64, (i / 2) as f64); - let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); - titled(&mut out, "rect", x, i, g); - } - } - "gear" => { - // Gear: up to three interlocking gears, the first the biggest. - let s = area.h.min(area.w * 0.62); - let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); - let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; - for (i, g) in gs.iter().take(3).enumerate() { - let (fx, fy, fs, preset) = spots[i]; - let z = s * fs; - titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); - } - } - "funnel" => { - // Funnel: the ideas poured in at the top (up to three), the last one coming out below. - let s = area.h.min(area.w); - let (cx, y0) = (area.x + area.w / 2.0, area.y); - let fw = s * 0.8; - let fh = s * 0.62; - let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); - paint(d, &mut f, 0, true); - out.push((f, vec![])); - let (inside, last) = match gs.split_last() { - Some((l, r)) if !r.is_empty() => (r, Some(l)), - _ => (&gs[..], None), - }; - let k = inside.len().min(3); - let dc = fw * 0.27; - let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; - for (i, g) in inside.iter().take(3).enumerate() { - let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; - titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); - } - if let Some(g) = last { - let a = s * 0.12; - let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); - paint(d, &mut ar, k, true); - out.push((ar, vec![])); - titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); - } - } - "pictureHList" => { - // Horizontal Picture List: a column per node, a big picture over its text. - let w = area.w / nf; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * w + w * 0.04; - let bw = w * 0.92; - let ph = area.h * 0.55; - out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); - let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); - if let Some(b) = &mut sh.text { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - } - paint(d, &mut sh, i, false); - out.push((sh, g.clone())); - } - } - "timeline" => { - // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and - // below in turn. - let mid = area.y + area.h / 2.0; - let bar = (area.h * 0.08).max(6.0); - let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - let w = area.w * 0.92 / nf; - for (i, g) in gs.iter().enumerate() { - let cx = area.x + w * (i as f64 + 0.5); - let dot = bar * 1.4; - let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); - paint(d, &mut dsh, i, false); - out.push((dsh, vec![])); - let th = area.h / 2.0 - bar * 1.6; - let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; - let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); - if let Some(b) = &mut t.text { - b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); - } - paint(d, &mut t, i, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - out.push((t, g.clone())); - } - } - "venn" => { - // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dc = if n == 1 { s } else { s * 0.62 }; - let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); - let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); - paint(d, &mut sh, i, false); - if let Some(Fill::Solid { color: c }) = &mut sh.fill { - *c = c.clone().with(ColorTransform::Alpha(50_000)); - } - if let Some(b) = &mut sh.text { - // Text towards the circle's outer side, clear of the overlap. - let p = dc * 0.36; - let (dx, dy) = (a.cos() * p, a.sin() * p); - b.body.inset_l = Some((7.2 + dx).max(2.0)); - b.body.inset_r = Some((7.2 - dx).max(2.0)); - b.body.inset_t = Some((3.6 + dy).max(2.0)); - b.body.inset_b = Some((3.6 - dy).max(2.0)); - } - out.push((sh, g.clone())); - } - } - "target" => { - // Basic Target: nested rings, the outermost first, each ring's text in a box beside it - // with a line from the ring. - let s = area.h.min(area.w * 0.5); - let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); - let band = s / 2.0 / nf; - for (i, _) in gs.iter().enumerate() { - let dc = s - 2.0 * band * i as f64; - let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - let tx = area.x + s + area.w * 0.06; - let rh = area.h / nf; - for (i, g) in gs.iter().enumerate() { - let ty = area.y + rh * (i as f64 + 0.5); - // The point on ring i's band level with the text, else its top. - let ry = cy - s / 2.0 + band * (i as f64 + 0.5); - out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); - let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); - paint(d, &mut t, i, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - if let Some(b) = &mut t.text { - for p in &mut b.paragraphs { - p.props.align = Some(Align::Left); - } - } - out.push((t, g.clone())); - } - } - _ => {} - } - out -} - -/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on -/// a row of their own between the parent and its other children. `half_circle` draws each node as -/// its text in an open half ring (Half Circle Organization Chart). -fn org_chart(d: &Diagram, area: Xfrm) -> Vec { - let half_circle = d.kind == "halfCircleOrgChart"; - let name_title = d.kind == "nameTitleOrgChart"; - let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; - if nodes.is_empty() { - return vec![]; - } - let mut parent: Vec> = vec![None; nodes.len()]; - let mut stack: Vec = vec![]; - for (i, n) in nodes.iter().enumerate() { - while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { - stack.pop(); - } - parent[i] = stack.last().copied(); - stack.push(i); - } - let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); - let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; - let mut leaves = vec![0usize; nodes.len()]; - for i in (0..nodes.len()).rev() { - let k = kids(Some(i)); - leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; - } - // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. - let mut slot = vec![0usize; nodes.len()]; - for i in 0..nodes.len() { - slot[i] = match parent[i] { - None => 0, - Some(p) if asst(i) => slot[p] + 1, - Some(p) => slot[p] + 2, - }; - } - let mut used: Vec = slot.clone(); - used.sort_unstable(); - used.dedup(); - let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); - let rows = used.len().max(1); - let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); - let cell_w = area.w / total as f64; - let row_h = area.h / rows as f64; - let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62); - let mut left = vec![0usize; nodes.len()]; - let mut next_root = 0; - for i in 0..nodes.len() { - if asst(i) { - continue; - } - let mut start = match parent[i] { - None => { - let s = next_root; - next_root += leaves[i]; - s - } - Some(p) => left[p], - }; - if let Some(p) = parent[i] { - for s in kids(Some(p)) { - if s == i { - break; - } - start += leaves[s]; - } - } - left[i] = start; - } - let mut side = vec![0usize; nodes.len()]; - let boxes: Vec = (0..nodes.len()) - .map(|i| { - let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; - match parent[i] { - Some(p) if asst(i) => { - // Assistants alternate right and left of the parent's trunk. - let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); - side[i] = k; - let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; - let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; - let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; - Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) - } - _ => { - let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; - Xfrm::new(cx - bw / 2.0, y, bw, bh) - } - } - }) - .collect(); - let mut out: Vec = vec![]; - for i in 0..nodes.len() { - let Some(p) = parent[i] else { continue }; - let (a, b) = (boxes[p], boxes[i]); - let pc = a.x + a.w / 2.0; - if asst(i) { - let my = b.y + b.h / 2.0; - out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); - let ex = if b.x > pc { b.x } else { b.x + b.w }; - out.push((line_shape((pc, my), (ex, my)), vec![])); - } else { - let mid = b.y - (row_h - bh) / 2.0; - let cc = b.x + b.w / 2.0; - out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); - out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); - out.push((line_shape((cc, mid), (cc, b.y)), vec![])); - } - } - let _ = side; - for (i, n) in nodes.iter().enumerate() { - let depth = slot[i] / 2; - if half_circle { - // Two half rings round the text: the upper left one in the accent, the lower right one - // in its tint. - let x = boxes[i]; - for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() { - let mut arc = deco("blockArc", x, 0.0); - arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] }; - paint(d, &mut arc, depth, false); - if k == 1 { - let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000)); - arc.fill = Some(Fill::solid(c)); - } - arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - arc.effects = None; - out.push((arc, vec![])); - } - let mut t = text_shape("rect", x, &[n], true); - paint(d, &mut t, depth, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - out.push((t, vec![i])); - } else { - let mut x = boxes[i]; - if name_title { - // The name in the upper part; the title box overlaps the lower edge. - x.h *= 0.85; - } - let mut sh = text_shape("rect", x, &[n], true); - paint(d, &mut sh, depth, false); - out.push((sh, vec![i])); - if name_title { - let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42); - let title = DiagramNode { text: n.title.clone(), ..Default::default() }; - let mut t = text_shape("rect", tb, &[&title], true); - paint(d, &mut t, depth, true); - t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); - if let Some(b) = &mut t.text { - for p in &mut b.paragraphs { - for r in &mut p.runs { - r.props.size = Some(11.0); - } - p.end_props.size = Some(11.0); - } - } - out.push((t, vec![])); - } - } - } - out -} - -/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. -fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { - let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; - if nodes.is_empty() { - return vec![]; - } - let mut parent: Vec> = vec![None; nodes.len()]; - let mut stack: Vec = vec![]; - for (i, n) in nodes.iter().enumerate() { - while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { - stack.pop(); - } - parent[i] = stack.last().copied(); - stack.push(i); - } - let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; - let mut leaves = vec![0usize; nodes.len()]; - for i in (0..nodes.len()).rev() { - let k = kids(Some(i)); - leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; - } - let depth: Vec = (0..nodes.len()) - .map(|i| { - let (mut d, mut p) = (0, parent[i]); - while let Some(q) = p { - d += 1; - p = parent[q]; - } - d - }) - .collect(); - let rows = depth.iter().max().copied().unwrap_or(0) + 1; - let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); - // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. - let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; - let cell_w = span / total as f64; - let row_h = across / rows as f64; - let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; - // Left edge (in leaves) of each node's span. - let mut left = vec![0usize; nodes.len()]; - let mut next_root = 0; - for i in 0..nodes.len() { - let mut start = match parent[i] { - None => { - let s = next_root; - next_root += leaves[i]; - s - } - Some(p) => left[p], - }; - if let Some(p) = parent[i] { - for s in kids(Some(p)) { - if s == i { - break; - } - start += leaves[s]; - } - } - left[i] = start; - } - let boxes: Vec = (0..nodes.len()) - .map(|i| { - let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; - let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; - if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } - }) - .collect(); - let mut out: Vec = nodes - .iter() - .enumerate() - .map(|(i, n)| { - let mut sh = text_shape("roundRect", boxes[i], &[n], true); - paint(d, &mut sh, depth[i], false); - (sh, vec![i]) - }) - .collect(); - for i in 0..nodes.len() { - if let Some(p) = parent[i] { - let (a, b) = (boxes[p], boxes[i]); - let (p, q) = if horizontal { - ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) - } else { - ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) - }; - out.push((line_shape(p, q), vec![])); - } - } - out -} - -/// The SmartArt group that `id` is, or is inside. -fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { - for sh in shapes { - if sh.id == id && sh.diagram.is_some() { - return Some(sh); - } - if let ShapeKind::Group { children, .. } = &sh.kind { - if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { - return Some(sh); - } - if let Some(g) = diagram_group(children, id) { - return Some(g); - } - } - } - None -} - -/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. -pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { - let shapes = current_shapes(&st.doc, &st.selection)?; - let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; - let mut d = g.diagram.clone()?; - if let ShapeKind::Group { children, .. } = &g.kind { - sync_text(&mut d, children); - } - Some((g.id, d)) -} - -fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { - let st = s.doc()?; - let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; - let ids = targets(s, p)?; - ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) -} - -/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. -fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result> { - let mut v = layout(d, area); - for sh in &mut v { - sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); - } - Ok(v) -} - -/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. -fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { - let gid = target_group(s, p, cmd)?; - s.edit(|doc, sel| { - let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; - let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; - sync_text(&mut d, &children); - let out = f(&mut d)?; - normalize(&mut d.nodes); - d.imported = false; - let kids = regenerate(cmd, doc, &d, child)?; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; - let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; - sh.kind = ShapeKind::Group { children: kids, child }; - sh.diagram = Some(d); - sel.shapes = vec![gid]; - sel.text = None; - Ok(out) - }) -} - -fn index(p: &Value, cmd: &str) -> Result { - p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) -} - -/// Insert a new SmartArt graphic (`insert.smartArt`). -pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { - let kind = if is_kind(kind) { kind } else { "process" }; - if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect(); - } - normalize(&mut nodes); - let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; - let size = s.doc()?.doc.slide_size; - let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); - let id = s.edit(|doc, sel| { - let children = regenerate("insert.smartArt", doc, &d, area)?; - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?); - let g = Shape { - id, - name: format!("SmartArt ({kind})"), - xfrm: Some(area), - kind: ShapeKind::Group { children, child: area }, - diagram: Some(d), - ..Default::default() - }; - crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); - sel.shapes = vec![id]; - sel.text = None; - Ok(id) - })?; - Ok(json!({"id": id.0})) -} - -fn nodes(s: &mut Session, p: &Value) -> Result { - let gid = target_group(s, p, "smartArt.nodes")?; - let g = shape_of(s, gid)?; - let mut d = g.diagram.unwrap_or_default(); - if let ShapeKind::Group { children, .. } = &g.kind { - sync_text(&mut d, children); - } - Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) -} - -fn set_nodes(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.setNodes"; - let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { - (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), - (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), - _ => None, - }; - let one = p.get("index").map(|_| index(p, CMD)).transpose()?; - let text = str_param(p, "text").map(String::from); - let title = str_param(p, "title").map(String::from); - edit_diagram(s, p, CMD, |d| { - if let Some(n) = given { - d.nodes = n; - } - if let (Some(i), Some(t)) = (one, text) { - d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; - } - if let (Some(i), Some(t)) = (one, title) { - d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t; - } - Ok(Value::Null) - }) -} - -fn add_node(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); - let pos = str_param(p, "position").unwrap_or("after").to_string(); - let text = str_param(p, "text").unwrap_or("").to_string(); - edit_diagram(s, p, "smartArt.addNode", |d| { - let at = add(&mut d.nodes, i, &pos, &text); - Ok(json!({"index": at})) - }) -} - -fn remove_node(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.removeNode"; - let i = index(p, CMD)?; - edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) -} - -fn promote(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.promote"; - let i = index(p, CMD)?; - edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) -} - -fn demote(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.demote"; - let i = index(p, CMD)?; - edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) -} - -/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in -/// the shape's place. -fn from_text(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.fromText"; - let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string(); - let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?; - let (area, nodes) = { - let st = s.doc()?; - let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; - let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?; - let body = - sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?; - let mut nodes: Vec = body - .paragraphs - .iter() - .filter(|p| !p.text().trim().is_empty()) - .map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() }) - .collect(); - normalize(&mut nodes); - // A small text box grows to a size a diagram can use, keeping its top left on the slide. - let mut a = xfrm_of(&st.doc, &st.selection, sh); - let size = st.doc.slide_size; - a.w = a.w.max(size.width * 0.5).min(size.width); - a.h = a.h.max(size.height * 0.35).min(size.height); - a.x = a.x.min(size.width - a.w).max(0.0); - a.y = a.y.min(size.height - a.h).max(0.0); - a.rot = 0.0; - (a, nodes) - }; - let d = Diagram { kind: kind.clone(), nodes, ..Default::default() }; - let gid = s.edit(|doc, sel| { - let children = regenerate(CMD, doc, &d, area)?; - let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); - let g = Shape { - id: gid, - name: format!("SmartArt ({kind})"), - xfrm: Some(area), - kind: ShapeKind::Group { children, child: area }, - diagram: Some(d), - ..Default::default() - }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?; - // In the text shape's place in the z-order. - shapes[at] = g; - sel.shapes = vec![gid]; - sel.text = None; - Ok(gid) - })?; - Ok(json!({"id": gid.0})) -} - -/// Convert to Text: a text box with the outline, one bulleted paragraph per node. -fn to_text(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.toText"; - let gid = target_group(s, p, CMD)?; - let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; - // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). - let (title_pt, bullet_pt) = { - let st = s.doc()?; - let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; - let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; - text_sizes(g.children()) - }; - let new = s.edit(|doc, sel| { - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; - let area = shapes[at].xfrm.unwrap_or_default(); - let mut body = TextBody::default(); - for n in &d.nodes { - let mut para = Paragraph::new(&n.text); - para.level = n.level.min(MAX_LEVEL); - let size = if n.level == 0 { title_pt } else { bullet_pt }; - for r in &mut para.runs { - r.props.size = Some(size); - } - para.end_props.size = Some(size); - para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); - para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); - para.props.indent = Some(-14.0); - body.paragraphs.push(para); - } - if body.paragraphs.is_empty() { - body.paragraphs.push(Paragraph::new("")); - } - body.body.wrap = Some(true); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); - let tb = - Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - shapes[at] = tb; - sel.shapes = vec![id]; - sel.text = None; - Ok(id) - })?; - Ok(json!({"id": new.0})) -} - -/// Convert to Shapes: keep the drawing as an ordinary group. -fn to_shapes(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.toShapes"; - let gid = target_group(s, p, CMD)?; - s.edit(|doc, sel| { - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?; - g.diagram = None; - g.name = "Group".into(); - sel.shapes = vec![gid]; - Ok(()) - })?; - Ok(Value::Null) -} - -/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). -fn text_sizes(children: &[Shape]) -> (f64, f64) { - let mut titles: Vec = vec![]; - let mut bullets: Vec = vec![]; - for c in children { - let Some(b) = &c.text else { continue }; - for p in &b.paragraphs { - let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; - if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } - } - } - let mode = |v: &mut Vec, d: f64| { - v.sort_by(|a, b| a.total_cmp(b)); - let mut best = (d, 0usize); - let mut k = 0; - while k < v.len() { - let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); - if j > best.1 { - best = (v[k], j); - } - k += j; - } - best.0 - }; - (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) -} - -/// The graphic `gid` with its outline (typed text synced). -fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { - let st = s.doc().ok()?; - let shapes = current_shapes(&st.doc, &st.selection)?; - let g = diagram_group(shapes, gid)?; - let mut d = g.diagram.clone()?; - if let ShapeKind::Group { children, .. } = &g.kind { - sync_text(&mut d, children); - } - Some((g.id, d)) -} - -fn set_picture(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.picture"; - let i = index(p, CMD)?; - let media = if bool_or(p, "reset", false) { - None - } else { - let (name, bytes) = super::insert::media_bytes(p, CMD)?; - let ct = super::insert::content_type(&name, &bytes); - target_group(s, p, CMD)?; - Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?) - }; - edit_diagram(s, p, CMD, |d| { - // A sub-point's picture is its top-level node's: that's the node the placeholder shows. - let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); - let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; - n.picture = media; - Ok(Value::Null) - }) -} - -fn change_style(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.style"; - let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); - edit_diagram(s, p, CMD, |d| { - if d.style != v { - d.style_xml = None; - } - d.style = v; - Ok(Value::Null) - }) -} - -fn change_colors(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.colors"; - let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string(); - edit_diagram(s, p, CMD, |d| { - if d.colors != v { - d.colors_xml = None; - } - d.colors = v; - Ok(Value::Null) - }) -} - -fn change_layout(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.layout"; - let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); - edit_diagram(s, p, CMD, |d| { - if d.kind != kind { - // PowerPoint's own layout no longer applies. - d.layout_id.clear(); - d.layout_xml = None; - } - d.kind = kind; - Ok(Value::Null) - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/smartart/diagrams.rs b/crates/engine/src/cmd/smartart/diagrams.rs new file mode 100644 index 000000000..c4df303ff --- /dev/null +++ b/crates/engine/src/cmd/smartart/diagrams.rs @@ -0,0 +1,254 @@ +use super::nodes::Item; +use super::paint::{accent, deco, line_shape, paint, text_shape}; +use super::*; + +/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on +/// a row of their own between the parent and its other children. `half_circle` draws each node as +/// its text in an open half ring (Half Circle Organization Chart). +pub(super) fn org_chart(d: &Diagram, area: Xfrm) -> Vec { + let half_circle = d.kind == "halfCircleOrgChart"; + let name_title = d.kind == "nameTitleOrgChart"; + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. + let mut slot = vec![0usize; nodes.len()]; + for i in 0..nodes.len() { + slot[i] = match parent[i] { + None => 0, + Some(p) if asst(i) => slot[p] + 1, + Some(p) => slot[p] + 2, + }; + } + let mut used: Vec = slot.clone(); + used.sort_unstable(); + used.dedup(); + let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); + let rows = used.len().max(1); + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + let cell_w = area.w / total as f64; + let row_h = area.h / rows as f64; + let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62); + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + if asst(i) { + continue; + } + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let mut side = vec![0usize; nodes.len()]; + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; + match parent[i] { + Some(p) if asst(i) => { + // Assistants alternate right and left of the parent's trunk. + let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); + side[i] = k; + let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; + let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; + let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; + Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) + } + _ => { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, y, bw, bh) + } + } + }) + .collect(); + let mut out: Vec = vec![]; + for i in 0..nodes.len() { + let Some(p) = parent[i] else { continue }; + let (a, b) = (boxes[p], boxes[i]); + let pc = a.x + a.w / 2.0; + if asst(i) { + let my = b.y + b.h / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); + let ex = if b.x > pc { b.x } else { b.x + b.w }; + out.push((line_shape((pc, my), (ex, my)), vec![])); + } else { + let mid = b.y - (row_h - bh) / 2.0; + let cc = b.x + b.w / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); + out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); + out.push((line_shape((cc, mid), (cc, b.y)), vec![])); + } + } + let _ = side; + for (i, n) in nodes.iter().enumerate() { + let depth = slot[i] / 2; + if half_circle { + // Two half rings round the text: the upper left one in the accent, the lower right one + // in its tint. + let x = boxes[i]; + for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() { + let mut arc = deco("blockArc", x, 0.0); + arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] }; + paint(d, &mut arc, depth, false); + if k == 1 { + let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000)); + arc.fill = Some(Fill::solid(c)); + } + arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + arc.effects = None; + out.push((arc, vec![])); + } + let mut t = text_shape("rect", x, &[n], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, vec![i])); + } else { + let mut x = boxes[i]; + if name_title { + // The name in the upper part; the title box overlaps the lower edge. + x.h *= 0.85; + } + let mut sh = text_shape("rect", x, &[n], true); + paint(d, &mut sh, depth, false); + out.push((sh, vec![i])); + if name_title { + let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42); + let title = DiagramNode { text: n.title.clone(), ..Default::default() }; + let mut t = text_shape("rect", tb, &[&title], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + p.end_props.size = Some(11.0); + } + } + out.push((t, vec![])); + } + } + } + out +} + +/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. +pub(super) fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + let depth: Vec = (0..nodes.len()) + .map(|i| { + let (mut d, mut p) = (0, parent[i]); + while let Some(q) = p { + d += 1; + p = parent[q]; + } + d + }) + .collect(); + let rows = depth.iter().max().copied().unwrap_or(0) + 1; + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. + let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; + let cell_w = span / total as f64; + let row_h = across / rows as f64; + let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; + // Left edge (in leaves) of each node's span. + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; + if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } + }) + .collect(); + let mut out: Vec = nodes + .iter() + .enumerate() + .map(|(i, n)| { + let mut sh = text_shape("roundRect", boxes[i], &[n], true); + paint(d, &mut sh, depth[i], false); + (sh, vec![i]) + }) + .collect(); + for i in 0..nodes.len() { + if let Some(p) = parent[i] { + let (a, b) = (boxes[p], boxes[i]); + let (p, q) = if horizontal { + ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) + } else { + ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) + }; + out.push((line_shape(p, q), vec![])); + } + } + out +} diff --git a/crates/engine/src/cmd/smartart/edit.rs b/crates/engine/src/cmd/smartart/edit.rs new file mode 100644 index 000000000..080a752f3 --- /dev/null +++ b/crates/engine/src/cmd/smartart/edit.rs @@ -0,0 +1,375 @@ +use super::nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove, sync_text}; +use super::paint::layout; +use super::*; + +/// The SmartArt group that `id` is, or is inside. +fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + for sh in shapes { + if sh.id == id && sh.diagram.is_some() { + return Some(sh); + } + if let ShapeKind::Group { children, .. } = &sh.kind { + if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { + return Some(sh); + } + if let Some(g) = diagram_group(children, id) { + return Some(g); + } + } + } + None +} + +/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. +pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; + let ids = targets(s, p)?; + ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) +} + +/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. +fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result> { + let mut v = layout(d, area); + for sh in &mut v { + sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); + } + Ok(v) +} + +/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. +fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { + let gid = target_group(s, p, cmd)?; + s.edit(|doc, sel| { + let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; + let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; + sync_text(&mut d, &children); + let out = f(&mut d)?; + normalize(&mut d.nodes); + d.imported = false; + let kids = regenerate(cmd, doc, &d, child)?; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; + sh.kind = ShapeKind::Group { children: kids, child }; + sh.diagram = Some(d); + sel.shapes = vec![gid]; + sel.text = None; + Ok(out) + }) +} + +fn index(p: &Value, cmd: &str) -> Result { + p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) +} + +/// Insert a new SmartArt graphic (`insert.smartArt`). +pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { + let kind = if is_kind(kind) { kind } else { "process" }; + if nodes.is_empty() { + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect(); + } + normalize(&mut nodes); + let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; + let size = s.doc()?.doc.slide_size; + let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); + let id = s.edit(|doc, sel| { + let children = regenerate("insert.smartArt", doc, &d, area)?; + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?); + let g = Shape { + id, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": id.0})) +} + +pub(super) fn nodes(s: &mut Session, p: &Value) -> Result { + let gid = target_group(s, p, "smartArt.nodes")?; + let g = shape_of(s, gid)?; + let mut d = g.diagram.unwrap_or_default(); + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) +} + +pub(super) fn set_nodes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.setNodes"; + let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { + (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), + (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), + _ => None, + }; + let one = p.get("index").map(|_| index(p, CMD)).transpose()?; + let text = str_param(p, "text").map(String::from); + let title = str_param(p, "title").map(String::from); + edit_diagram(s, p, CMD, |d| { + if let Some(n) = given { + d.nodes = n; + } + if let (Some(i), Some(t)) = (one, text) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; + } + if let (Some(i), Some(t)) = (one, title) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t; + } + Ok(Value::Null) + }) +} + +pub(super) fn add_node(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + let pos = str_param(p, "position").unwrap_or("after").to_string(); + let text = str_param(p, "text").unwrap_or("").to_string(); + edit_diagram(s, p, "smartArt.addNode", |d| { + let at = add(&mut d.nodes, i, &pos, &text); + Ok(json!({"index": at})) + }) +} + +pub(super) fn remove_node(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.removeNode"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) +} + +pub(super) fn promote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.promote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) +} + +pub(super) fn demote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.demote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) +} + +/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in +/// the shape's place. +pub(super) fn from_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.fromText"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string(); + let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?; + let (area, nodes) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?; + let body = + sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?; + let mut nodes: Vec = body + .paragraphs + .iter() + .filter(|p| !p.text().trim().is_empty()) + .map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() }) + .collect(); + normalize(&mut nodes); + // A small text box grows to a size a diagram can use, keeping its top left on the slide. + let mut a = xfrm_of(&st.doc, &st.selection, sh); + let size = st.doc.slide_size; + a.w = a.w.max(size.width * 0.5).min(size.width); + a.h = a.h.max(size.height * 0.35).min(size.height); + a.x = a.x.min(size.width - a.w).max(0.0); + a.y = a.y.min(size.height - a.h).max(0.0); + a.rot = 0.0; + (a, nodes) + }; + let d = Diagram { kind: kind.clone(), nodes, ..Default::default() }; + let gid = s.edit(|doc, sel| { + let children = regenerate(CMD, doc, &d, area)?; + let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); + let g = Shape { + id: gid, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?; + // In the text shape's place in the z-order. + shapes[at] = g; + sel.shapes = vec![gid]; + sel.text = None; + Ok(gid) + })?; + Ok(json!({"id": gid.0})) +} + +/// Convert to Text: a text box with the outline, one bulleted paragraph per node. +pub(super) fn to_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toText"; + let gid = target_group(s, p, CMD)?; + let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). + let (title_pt, bullet_pt) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + text_sizes(g.children()) + }; + let new = s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; + let area = shapes[at].xfrm.unwrap_or_default(); + let mut body = TextBody::default(); + for n in &d.nodes { + let mut para = Paragraph::new(&n.text); + para.level = n.level.min(MAX_LEVEL); + let size = if n.level == 0 { title_pt } else { bullet_pt }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-14.0); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + body.body.wrap = Some(true); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); + let tb = + Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + shapes[at] = tb; + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": new.0})) +} + +/// Convert to Shapes: keep the drawing as an ordinary group. +pub(super) fn to_shapes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toShapes"; + let gid = target_group(s, p, CMD)?; + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?; + g.diagram = None; + g.name = "Group".into(); + sel.shapes = vec![gid]; + Ok(()) + })?; + Ok(Value::Null) +} + +/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). +fn text_sizes(children: &[Shape]) -> (f64, f64) { + let mut titles: Vec = vec![]; + let mut bullets: Vec = vec![]; + for c in children { + let Some(b) = &c.text else { continue }; + for p in &b.paragraphs { + let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; + if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } + } + } + let mode = |v: &mut Vec, d: f64| { + v.sort_by(|a, b| a.total_cmp(b)); + let mut best = (d, 0usize); + let mut k = 0; + while k < v.len() { + let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); + if j > best.1 { + best = (v[k], j); + } + k += j; + } + best.0 + }; + (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) +} + +/// The graphic `gid` with its outline (typed text synced). +fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { + let st = s.doc().ok()?; + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = diagram_group(shapes, gid)?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +pub(super) fn set_picture(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.picture"; + let i = index(p, CMD)?; + let media = if bool_or(p, "reset", false) { + None + } else { + let (name, bytes) = crate::cmd::insert::media_bytes(p, CMD)?; + let ct = crate::cmd::insert::content_type(&name, &bytes); + target_group(s, p, CMD)?; + Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?) + }; + edit_diagram(s, p, CMD, |d| { + // A sub-point's picture is its top-level node's: that's the node the placeholder shows. + let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); + let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; + n.picture = media; + Ok(Value::Null) + }) +} + +pub(super) fn change_style(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.style"; + let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.style != v { + d.style_xml = None; + } + d.style = v; + Ok(Value::Null) + }) +} + +pub(super) fn change_colors(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.colors"; + let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.colors != v { + d.colors_xml = None; + } + d.colors = v; + Ok(Value::Null) + }) +} + +pub(super) fn change_layout(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.layout"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.kind != kind { + // PowerPoint's own layout no longer applies. + d.layout_id.clear(); + d.layout_xml = None; + } + d.kind = kind; + Ok(Value::Null) + }) +} diff --git a/crates/engine/src/cmd/smartart/layout.rs b/crates/engine/src/cmd/smartart/layout.rs new file mode 100644 index 000000000..baeda540b --- /dev/null +++ b/crates/engine/src/cmd/smartart/layout.rs @@ -0,0 +1,752 @@ +use super::diagrams::{hierarchy, org_chart}; +use super::nodes::{Item, groups}; +use super::paint::{accent, deco, line_shape, paint, picture_shape, text_shape}; +use super::*; + +pub(super) fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { + let gs = groups(d); + let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; + let n = gs.len().max(1); + let nf = n as f64; + let mut out: Vec = vec![]; + // A titled shape for top-level group `i`, painted in its accent. + let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { + let mut sh = text_shape(preset, x, &nodes(g), true); + paint(d, &mut sh, i, false); + out.push((sh, g.to_vec())); + }; + let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { + let mut sh = deco("rightArrow", x, rot); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + }; + match d.kind.as_str() { + "hierarchy" => return hierarchy(d, area, false), + "horizontalHierarchy" => return hierarchy(d, area, true), + "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), + "list" | "hBulletList" => { + // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. + let vertical = d.kind == "list"; + for (i, g) in gs.iter().enumerate() { + let (cell, bar_h) = if vertical { + ( + Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), + (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), + ) + } else { + let w = area.w / nf; + (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) + }; + let has_sub = g.len() > 1; + let bar_h = bar_h.min(cell.h); + titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); + if has_sub { + let pad = cell.w * 0.03; + let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); + let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + out.push((sh, g[1..].to_vec())); + } + } + } + "blockList" => { + // Basic Block List: equal blocks in rows, as square as the area allows. + let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (w, h) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; + titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); + } + } + "process" | "chevron" => { + let chevron = d.kind == "chevron"; + let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; + let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); + let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); + let y = area.y + (area.h - h) / 2.0; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); + if !chevron && i + 1 < n { + let aw = gap * 0.7; + arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); + } + } + } + "verticalProcess" => { + let gap = area.h * 0.06; + let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); + let w = area.w * 0.6; + let x = area.x + (area.w - w) / 2.0; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * (h + gap); + titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); + if i + 1 < n { + let s = gap * 0.8; + // A right arrow turned to point down. + arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); + } + } + } + "stepUp" => { + // Step Up Process: each step higher than the one before. + let w = area.w / nf; + let h = area.h / (nf + 1.0) * 1.6; + for (i, g) in gs.iter().enumerate() { + let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); + titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); + } + } + "cycle" | "blockCycle" => { + let block = d.kind == "blockCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; + titled( + &mut out, + if block { "roundRect" } else { "ellipse" }, + Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), + i, + g, + ); + if n > 1 { + // An arrow halfway to the next node, along the circle. + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.35).max(6.0); + let rot = (i as f64 + 0.5) / nf * 360.0; + arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); + } + } + } + "radial" => { + // Basic Radial: the first node in the middle, the others around it with spokes. + let Some((centre, rest)) = gs.split_first() else { return out }; + let m = rest.len().max(1) as f64; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = r * 0.75; + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); + for (k, _) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); + out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); + } + titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); + for (k, g) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); + } + } + "pyramid" | "invertedPyramid" => { + // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as + // its own polygon so the sides keep the triangle's slope and the text stays upright. + let inverted = d.kind == "invertedPyramid"; + let h = area.h / nf; + let pw = area.h.min(area.w) * 1.15; + let px = area.x + (area.w - pw) / 2.0; + for (i, g) in gs.iter().enumerate() { + let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); + let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; + let (wt, wb) = (pw * t, pw * bt); + let w = wt.max(wb).max(1.0); + let bh = h * 0.96; + let x = px + (pw - w) / 2.0; + let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); + let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. + // The tip band is a triangle: its text sits at the wide end, inset to the width there. + let tip = wt.min(wb) < 1e-6; + let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; + if let Some(b) = &mut sh.text { + b.body.inset_l = Some(inset); + b.body.inset_r = Some(inset); + if tip { + b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } + } + "matrix" | "titledMatrix" => { + let titled_m = d.kind == "titledMatrix"; + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let (quads, title): (&[Vec], Option<&Vec>) = + if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; + for (i, g) in quads.iter().take(4).enumerate() { + let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); + titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); + } + if let Some(t) = title { + let c = s * 0.42; + let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); + paint(d, &mut sh, 0, true); + out.push((sh, t.clone())); + } + } + "pictureList" => { + // Vertical Picture List: a picture square at the left of each row, the text beside it. + let h = area.h / nf; + let bh = h * 0.88; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let s = bh.min(area.w * 0.3); + out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); + titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); + } + } + "pictureCaption" | "pictureProcess" => { + // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one + // row, arrows between the steps. + let process = d.kind == "pictureProcess"; + let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; + let rows = n.div_ceil(cols); + let gap = if process { area.w * 0.05 } else { 0.0 }; + let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let h = area.h / rows as f64; + let bw = if process { w } else { w * 0.9 }; + let ch = (h * 0.9).min(bw * 1.1); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; + let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; + let y = area.y + r as f64 * h + (h - ch) / 2.0; + let ph = ch * 0.62; + out.push(( + picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), + vec![], + )); + titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); + if process && i + 1 < n { + let a = (gap * 0.7).min(ch * 0.2); + arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } + "textCycle" | "continuousCycle" => { + // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on + // a ring. + let text_only = d.kind == "textCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + if !text_only { + let ring = r * 2.0 + dd * 0.3; + let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); + sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = (dd * 1.35, dd * 0.7); + let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); + paint(d, &mut sh, i, false); + if text_only { + sh.fill = Some(Fill::None); + sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + sh.effects = None; + let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); + if let Some(b) = &mut sh.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.3).max(6.0); + arrow( + &mut out, + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), + (i as f64 + 0.5) / nf * 360.0, + i, + ); + } + } + out.push((sh, g.clone())); + } + } + "segmentedCycle" | "pie" => { + // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. + // The text sits in a clear box over each segment's middle. + let ring = d.kind == "segmentedCycle"; + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let gap = if ring { 2.0 } else { 0.0 }; + for (i, g) in gs.iter().enumerate() { + // Angles clockwise from 3 o'clock, starting at the top. + let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; + let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; + let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); + let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); + // blockArc runs from its first angle to its second clockwise; so does pie. + sh.geom = Geom::Preset { + name: if ring { "blockArc" } else { "pie" }.into(), + adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, + }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let m = ((a0 + a1) / 2.0).to_radians(); + let rr = if ring { s * 0.4375 } else { s * 0.3 }; + let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); + let th = s * 0.16; + let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + "multidirectionalCycle" => { + // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.5).max(8.0); + let mut sh = deco( + "leftRightArrow", + Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), + (i as f64 + 0.5) / nf * 360.0, + ); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + } + } + } + "cycleMatrix" => { + // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each + // quarter's sub-points in a box at its corner. + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let c = s * 0.56; + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let b = s * 0.4; + let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); + if g.len() > 1 { + let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(body) = &mut sh.text { + // Text in the box's outer part, clear of the circle. + body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); + body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); + body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + out.push((sh, g[1..].to_vec())); + } + } + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; + let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); + sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let q = c * 0.3; + let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g[..1].to_vec())); + } + } + "segmentedPyramid" => { + // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, + // every other one upside down. + let rows = (nf.sqrt().ceil() as usize).max(1); + let s = area.h.min(area.w / 1.15); + let pw = s * 1.15; + let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); + let (tw, th) = (pw / rows as f64, s / rows as f64); + let mut k = 0; + 'rows: for r in 0..rows { + let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; + for c in 0..(2 * r + 1) { + let Some(g) = gs.get(k) else { break 'rows }; + let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); + let mut sh = text_shape("rect", x, &nodes(g), true); + // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. + let path = if c % 2 == 1 { + format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) + } else { + format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: tw, + h: th, + d: path, + fill: deckcraft_geom::preset::FillMode::Norm, + stroke: true, + }], + }; + if let Some(b) = &mut sh.text { + // The text in the triangle's wide half. + b.body.inset_l = Some(tw * 0.22); + b.body.inset_r = Some(tw * 0.22); + if c % 2 == 1 { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + b.body.inset_b = Some(th * 0.35); + } else { + b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + b.body.inset_t = Some(th * 0.35); + } + } + paint(d, &mut sh, k, false); + out.push((sh, g.clone())); + k += 1; + } + } + } + "verticalBoxList" => { + // Vertical Box List: a pale box per node with its sub-points, its title on a tab + // overlapping the box's top left. + let h = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let tab_h = (h * 0.3).min(40.0); + let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false); + paint(d, &mut bx, i, true); + if let Some(b) = &mut bx.text { + b.body.inset_t = Some(tab_h * 0.6); + } + out.push((bx, g[1..].to_vec())); + titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); + } + } + "trapezoidList" | "tableList" => { + // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the + // first node as a header bar over a column per other node. + let table = d.kind == "tableList"; + let (head, cols): (Option<&Vec>, &[Vec]) = + if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) }; + let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; + if let Some(h) = head { + titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); + } + let m = cols.len().max(1) as f64; + let w = area.w / m; + for (k, g) in cols.iter().enumerate() { + let i = k + usize::from(table); + let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); + let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + if !table { + b.body.inset_l = Some(x.w * 0.2); + b.body.inset_r = Some(x.w * 0.2); + b.body.inset_t = Some(x.h * 0.08); + } + } + paint(d, &mut sh, i, table); + out.push((sh, g.clone())); + } + } + "accentProcess" => { + // Accent Process: a title box per step with its sub-points in a pale box below and to the + // right, arrows between the titles. + let gap = area.w * 0.05; + let w = (area.w - gap * (nf - 1.0)) / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + let th = area.h * 0.22; + if g.len() > 1 { + let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(b) = &mut sh.text { + b.body.inset_t = Some(th * 0.45); + } + out.push((sh, g[1..].to_vec())); + } + titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); + if i + 1 < n { + let a = gap * 0.8; + arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); + } + } + } + "continuousBlockProcess" => { + // Continuous Block Process: one big pale arrow behind a row of boxes. + let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); + paint(d, &mut big, 0, true); + out.push((big, vec![])); + let w = area.w * 0.85 / nf; + let h = area.h * 0.32; + for (i, g) in gs.iter().enumerate() { + let x = area.x + area.w * 0.02 + i as f64 * w; + titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); + } + } + "bendingProcess" => { + // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. + let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let x = area.x + c as f64 * cw; + let y = area.y + r as f64 * rh + (rh - bh) / 2.0; + titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g); + if i + 1 < n { + let a = (cw - bw) * 0.7; + if c + 1 < cols { + arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); + } else { + // The bend: down from the row's last box towards the next row's first. + let a = (rh - bh).min(a * 1.5).max(6.0); + arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); + } + } + } + } + "linearVenn" | "stackedVenn" => { + // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, + // the text near each one's top. + let linear = d.kind == "linearVenn"; + if linear { + let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); + let step = dc * 0.75; + let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; + for (i, g) in gs.iter().enumerate() { + let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(55_000)); + } + out.push((sh, g.clone())); + } + } else { + let s = area.h.min(area.w); + let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); + for (i, _) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + // Each circle's text in the band between its top and the next circle's. + let band = s / nf; + for (i, g) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let top = bottom - dc; + let h = if i + 1 < n { band } else { dc }; + let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; + let mut t = + text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + } + "nestedTarget" => { + // Nested Target: rounded boxes nested inside each other, the text on each one's top band. + let band_x = area.w / 2.0 / nf * 0.6; + let band_y = area.h / nf * 0.9 / 2.0; + for (i, _) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; + let mut t = text_shape( + "rect", + Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), + &nodes(g), + true, + ); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } + "gridMatrix" => { + // Grid Matrix: four quadrants on crossed axes. + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let t = s * 0.06; + for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] + { + let mut sh = deco(preset, x, 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + let q = s / 2.0 - t * 1.2; + for (i, g) in gs.iter().take(4).enumerate() { + let (c, r) = ((i % 2) as f64, (i / 2) as f64); + let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); + titled(&mut out, "rect", x, i, g); + } + } + "gear" => { + // Gear: up to three interlocking gears, the first the biggest. + let s = area.h.min(area.w * 0.62); + let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); + let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; + for (i, g) in gs.iter().take(3).enumerate() { + let (fx, fy, fs, preset) = spots[i]; + let z = s * fs; + titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); + } + } + "funnel" => { + // Funnel: the ideas poured in at the top (up to three), the last one coming out below. + let s = area.h.min(area.w); + let (cx, y0) = (area.x + area.w / 2.0, area.y); + let fw = s * 0.8; + let fh = s * 0.62; + let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); + paint(d, &mut f, 0, true); + out.push((f, vec![])); + let (inside, last) = match gs.split_last() { + Some((l, r)) if !r.is_empty() => (r, Some(l)), + _ => (&gs[..], None), + }; + let k = inside.len().min(3); + let dc = fw * 0.27; + let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; + for (i, g) in inside.iter().take(3).enumerate() { + let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; + titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); + } + if let Some(g) = last { + let a = s * 0.12; + let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); + paint(d, &mut ar, k, true); + out.push((ar, vec![])); + titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); + } + } + "pictureHList" => { + // Horizontal Picture List: a column per node, a big picture over its text. + let w = area.w / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * w + w * 0.04; + let bw = w * 0.92; + let ph = area.h * 0.55; + out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } + } + "timeline" => { + // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and + // below in turn. + let mid = area.y + area.h / 2.0; + let bar = (area.h * 0.08).max(6.0); + let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + let w = area.w * 0.92 / nf; + for (i, g) in gs.iter().enumerate() { + let cx = area.x + w * (i as f64 + 0.5); + let dot = bar * 1.4; + let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); + paint(d, &mut dsh, i, false); + out.push((dsh, vec![])); + let th = area.h / 2.0 - bar * 1.6; + let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; + let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); + if let Some(b) = &mut t.text { + b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); + } + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, g.clone())); + } + } + "venn" => { + // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = if n == 1 { s } else { s * 0.62 }; + let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); + let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(50_000)); + } + if let Some(b) = &mut sh.text { + // Text towards the circle's outer side, clear of the overlap. + let p = dc * 0.36; + let (dx, dy) = (a.cos() * p, a.sin() * p); + b.body.inset_l = Some((7.2 + dx).max(2.0)); + b.body.inset_r = Some((7.2 - dx).max(2.0)); + b.body.inset_t = Some((3.6 + dy).max(2.0)); + b.body.inset_b = Some((3.6 - dy).max(2.0)); + } + out.push((sh, g.clone())); + } + } + "target" => { + // Basic Target: nested rings, the outermost first, each ring's text in a box beside it + // with a line from the ring. + let s = area.h.min(area.w * 0.5); + let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); + let band = s / 2.0 / nf; + for (i, _) in gs.iter().enumerate() { + let dc = s - 2.0 * band * i as f64; + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + let tx = area.x + s + area.w * 0.06; + let rh = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let ty = area.y + rh * (i as f64 + 0.5); + // The point on ring i's band level with the text, else its top. + let ry = cy - s / 2.0 + band * (i as f64 + 0.5); + out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); + let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + p.props.align = Some(Align::Left); + } + } + out.push((t, g.clone())); + } + } + _ => {} + } + out +} diff --git a/crates/engine/src/cmd/smartart/nodes.rs b/crates/engine/src/cmd/smartart/nodes.rs new file mode 100644 index 000000000..5c3fd7aca --- /dev/null +++ b/crates/engine/src/cmd/smartart/nodes.rs @@ -0,0 +1,146 @@ +use super::paint::layout_items; +use super::*; + +/// Nodes from text lines: each leading tab is one outline level. +pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { + lines + .into_iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let tabs = l.chars().take_while(|c| *c == '\t').count(); + DiagramNode { + text: l.trim().to_string(), + level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), + picture: None, + assistant: false, + title: String::new(), + } + }) + .collect() +} + +/// Levels made consistent: the first node is top level and no node is more than one level +/// deeper than the node before it. +pub fn normalize(nodes: &mut [DiagramNode]) { + let mut prev: Option = None; + for n in nodes.iter_mut() { + n.level = match prev { + None => 0, + Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), + }; + prev = Some(n.level); + } +} + +/// Index one past the last descendant of node `i`. +fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { + let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); + let mut j = i + 1; + while nodes.get(j).is_some_and(|n| n.level > lv) { + j += 1; + } + j +} + +/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the +/// new node's index. +pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { + let Some(i) = index.filter(|i| *i < nodes.len()) else { + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() }); + return nodes.len() - 1; + }; + let lv = nodes[i].level; + let (at, level) = match position { + "before" => (i, lv), + "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), + "above" => { + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level = (n.level + 1).min(MAX_LEVEL); + } + (i, lv) + } + "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), + _ => (subtree_end(nodes, i), lv), + }; + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() }); + at +} + +/// Remove node `i`; its sub-points move up one level. +pub fn remove(nodes: &mut Vec, i: usize) -> bool { + if i >= nodes.len() { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i + 1..end] { + n.level = n.level.saturating_sub(1); + } + nodes.remove(i); + normalize(nodes); + true +} + +/// Move node `i` and its sub-points one level up. False when it is already top level. +pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + if nodes.get(i).is_none_or(|n| n.level == 0) { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level -= 1; + } + true +} + +/// Move node `i` and its sub-points one level down, under the node before it. False when there is +/// nothing to go under. +pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; + if cur.level > prev.level || cur.level >= MAX_LEVEL { + return false; + } + let end = subtree_end(nodes, i); + if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { + return false; + } + for n in &mut nodes[i..end] { + n.level += 1; + } + true +} + +/// A generated shape and the nodes whose text it shows (none for arrows and lines). +pub(super) type Item = (Shape, Vec); + +/// Top-level nodes, each with its sub-points. +pub(super) fn groups(d: &Diagram) -> Vec> { + let mut out: Vec> = vec![]; + for (i, n) in d.nodes.iter().enumerate() { + match out.last_mut() { + Some(g) if n.level > 0 => g.push(i), + _ => out.push(vec![i]), + } + } + out.truncate(MAX_SHAPES); + out +} + +/// Read text typed into the diagram's shapes back into its nodes. +pub(super) fn sync_text(d: &mut Diagram, children: &[Shape]) { + if d.imported { + return; + } + let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); + for (shape, idx) in children.iter().zip(map) { + let Some(body) = &shape.text else { continue }; + if idx.is_empty() || body.paragraphs.len() != idx.len() { + continue; + } + for (p, i) in body.paragraphs.iter().zip(idx) { + if let Some(n) = d.nodes.get_mut(i) { + n.text = p.text(); + } + } + } +} diff --git a/crates/engine/src/cmd/smartart/paint.rs b/crates/engine/src/cmd/smartart/paint.rs new file mode 100644 index 000000000..39286063b --- /dev/null +++ b/crates/engine/src/cmd/smartart/paint.rs @@ -0,0 +1,263 @@ +use super::layout::layout_raw; +use super::nodes::Item; +use super::*; + +fn slot(i: usize) -> SchemeSlot { + [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] +} + +/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` +/// keeps one. +pub(super) fn accent(d: &Diagram, i: usize) -> SchemeSlot { + let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); + if let Some(r) = d.colors.strip_prefix("range") + && let Some(a @ 1..=6) = digit(r, 0) + { + // Colorful Range: alternate between two neighbouring accents. + return slot(a - 1 + i % 2); + } + match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { + Some(n @ 1..=6) => slot(n - 1), + _ => slot(i), + } +} + +/// Gradient Range: each shape a step darker than the one before (the steps cycle). +pub(super) fn shade(d: &Diagram, i: usize) -> Option { + d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) +} + +/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) +/// lit by the three-point rig. +fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { + format!( + "", + (w * 12700.0).round() as i64, + (h * 12700.0).round() as i64 + ) +} + +/// Brick Scene's camera: the diagram's shapes seen from above and to the side. +pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; + +/// Whether `style` is one of the 3-D SmartArt Styles. +pub fn is_3d(style: &str) -> bool { + matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") +} + +/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet +/// boxes of the bullet lists) are a pale tint of the accent with dark text. +pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { + let a = accent(d, i); + let base = match shade(d, i) { + Some(t) => ColorRef::scheme(a).with(t), + None => ColorRef::scheme(a), + }; + let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); + let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); + sh.style = Some(ShapeStyle::accent(a)); + match d.style.as_str() { + _ if light => { + sh.fill = Some(Fill::solid(tint(80))); + sh.line = Some(Line::solid(tint(60), 1.0)); + } + "outline" => { + sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + sh.line = Some(Line::solid(base.clone(), 2.25)); + } + "subtle" => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); + } + "polished" | "inset" | "cartoon" | "powder" | "brick" => { + let (bevel, bw, line, fill) = match d.style.as_str() { + "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), + "inset" => ("relaxedInset", 10.0, None, base.clone()), + "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), + "powder" => ("convex", 14.0, None, tint(40)), + _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), + }; + sh.fill = Some(Fill::solid(fill)); + sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); + sh.effects = Some(Effects { + outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), + bevel: Some(bevel.into()), + raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), + ..Default::default() + }); + } + "intense" => { + sh.fill = Some(Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: tint(30) }, + GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); + } + _ => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + } + if let Some(body) = &mut sh.text { + let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } +} + +fn shadow(blur: f64, dist: f64) -> Shadow { + Shadow { + color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), + blur, + dist, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With +/// `title` false every line is a bullet (the bullet boxes of the bullet lists). +pub(super) fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { + let mut body = TextBody::default(); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); + let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); + for (k, n) in lines.iter().enumerate() { + let mut para = Paragraph::new(&n.text); + let head = title && k == 0; + if head { + para.props.align = Some(Align::Center); + } else { + para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); + para.props.align = Some(Align::Left); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-10.0); + } + let size = if head { 20.0 } else { 14.0 }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + if !title { + body.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } +} + +/// A plain shape with no text (arrows between steps). +pub(super) fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { + let mut x = x; + x.rot = rot; + Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } +} + +pub(super) fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { + let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); + x.flip_h = (b.0 < a.0) != (b.1 < a.1); + Shape { + name: "SmartArt connector".into(), + xfrm: Some(x), + geom: Geom::preset("line"), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), + ..Default::default() + } +} + +/// The diagram's shapes laid out in `area` (ids not yet assigned). +pub fn layout(d: &Diagram, area: Xfrm) -> Vec { + layout_items(d, area).into_iter().map(|(s, _)| s).collect() +} + +/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is +/// shrunk per shape so its longest word fits on a line. +pub(super) fn layout_items(d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout_raw(d, area); + for (sh, _) in &mut v { + fit_words(sh); + } + v +} + +/// Rough advance of a character as a fraction of the font size (a sans text face). +const CHAR_W: f64 = 0.6; + +/// Smallest font scale shrinking to fit goes to. +pub(super) const MIN_SCALE: f64 = 0.25; + +/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so +/// no word is wider than its line and would break mid-word. Overflow in height is left to the +/// body's shrink-on-overflow autofit. +fn fit_words(sh: &mut Shape) { + let Some(w) = sh.xfrm.map(|x| x.w) else { return }; + // Round shapes keep their text in the inscribed rectangle (about 71% of the width). + let text_w = match sh.geom.preset_name() { + Some("ellipse" | "gear6" | "gear9") => w * 0.71, + _ => w, + }; + let Some(body) = &mut sh.text else { return }; + let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); + let mut scale: f64 = 1.0; + for p in &body.paragraphs { + let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); + for r in &p.runs { + let size = r.props.size.unwrap_or(18.0); + let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; + let need = word * size * CHAR_W; + if need > 0.0 { + scale = scale.min(line.max(0.0) / need); + } + } + } + let scale = scale.max(MIN_SCALE); + if scale >= 0.999 { + return; + } + let shrink = |v: &mut Option| { + if let Some(x) = v { + *x = (*x * scale * 2.0).floor() / 2.0; + } + }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + shrink(&mut r.props.size); + } + shrink(&mut p.end_props.size); + shrink(&mut p.props.margin_left); + if let Some(i) = &mut p.props.indent { + *i *= scale; + } + } +} + +/// A picture placeholder for node `node`: its picture when it has one, else a pale box. +pub(super) fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { + let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; + paint(d, &mut sh, i, true); + if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { + sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + sh +} From 15bf82153597278fc25ed07fbc9112d4cada044c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:06:27 +0000 Subject: [PATCH 52/90] refactor(engine): split cmd/text.rs into cmd/text/{ime,layout,editing,autocorrect,caret,formatting}.rs --- crates/engine/src/cmd/text.rs | 2061 +-------------------- crates/engine/src/cmd/text/autocorrect.rs | 384 ++++ crates/engine/src/cmd/text/caret.rs | 393 ++++ crates/engine/src/cmd/text/editing.rs | 217 +++ crates/engine/src/cmd/text/formatting.rs | 243 +++ crates/engine/src/cmd/text/ime.rs | 620 +++++++ crates/engine/src/cmd/text/layout.rs | 198 ++ 7 files changed, 2078 insertions(+), 2038 deletions(-) create mode 100644 crates/engine/src/cmd/text/autocorrect.rs create mode 100644 crates/engine/src/cmd/text/caret.rs create mode 100644 crates/engine/src/cmd/text/editing.rs create mode 100644 crates/engine/src/cmd/text/formatting.rs create mode 100644 crates/engine/src/cmd/text/ime.rs create mode 100644 crates/engine/src/cmd/text/layout.rs diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fc4c81d52..6688f17af 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -15,6 +15,29 @@ use super::{ }; use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo}; +mod autocorrect; +mod caret; +mod editing; +mod formatting; +mod ime; +mod layout; + +use autocorrect::autocorrect_options; +pub use autocorrect::{AUTOCORRECT, AutoList, correct_word}; +#[cfg(test)] +use caret::count_param; +pub(crate) use caret::{apply_leading_tabs, delete_dir}; +use caret::{delete, delete_surrounding, get, insert, move_caret, select, select_all, select_para, select_word, set}; +use editing::{edit, exit}; +pub(crate) use editing::{insert_field, insert_text, paste_body}; +pub use formatting::format_range; +use formatting::{fonts_used, replace_font}; +pub(crate) use formatting::{format_body, format_para, format_run, link_text}; +pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines}; +use ime::{commit, compose}; +pub(crate) use layout::ctx_for; +pub use layout::{caret_color, caret_line, fit_text_box, layout_for, text_frame, text_pos_at}; + /// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands /// with their parameter docs and enabled predicates. pub fn specs() -> Vec { @@ -140,240 +163,6 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti Some(sh.text.get_or_insert_with(TextBody::default)) } -/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query. -/// -/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the -/// engine answer without copying a whole body or document. -pub const IME_SURROUNDING_MAX_BYTES: usize = 4000; - -/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8 -/// byte offsets. The current input-method composition (pre-edit) is removed from both the text -/// and the offsets. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ImeSurrounding { - pub text: String, - pub cursor: usize, - pub anchor: usize, -} - -/// How a soft line break reads in the IME view: a newline, like a paragraph break. -/// -/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would -/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines -/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change. -fn ime_char(ch: char) -> char { - if ch == '\u{b}' { '\n' } else { ch } -} - -/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`. -fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { - p.runs - .iter() - .flat_map(|run| run.content().chars()) - .enumerate() - .filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))) -} - -/// The same characters, newest first, without walking the paragraph from its start. -fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { - let mut starts = Vec::with_capacity(p.runs.len()); - let mut at = 0usize; - for r in &p.runs { - let n = r.char_len(); - starts.push((at, n)); - at += n; - } - p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| { - let content = r.content(); - content.chars().rev().enumerate().filter_map(move |(k, ch)| { - let i = start + n - 1 - k; - (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))) - }) - }) -} - -/// The composition's `(paragraph, start, end)` character range, when it is inside the body. -fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> { - let c = c?; - let para = body.paragraphs.get(c.start.0)?; - let start = c.start.1.min(para.char_len()); - let end = start.saturating_add(c.len).min(para.char_len()); - (start < end).then_some((c.start.0, start, end)) -} - -/// Return the composition character range only when its paragraph index equals `pi`. -fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> { - comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e)) -} - -/// Clip a model position to the body. An empty body only has position `(0, 0)`. -fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> { - if body.paragraphs.is_empty() { - return (pos == (0, 0)).then_some((0, 0)); - } - body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len()))) -} - -/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole -/// characters), stopping at the end of the body. Returns the position reached and the characters -/// and bytes consumed. -/// -/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks -/// that paragraph's runs from their start: runs do not cache character lengths, and a position is -/// a character index. A megabyte paragraph therefore costs time proportional to the caret's -/// character offset, even though the copied surrounding text stays within `limit`. -fn walk_forward( - body: &TextBody, - comp: Option<(usize, usize, usize)>, - from: (usize, usize), - limit: usize, - bytes: bool, -) -> ((usize, usize), usize, usize) { - let (mut pi, mut at) = from; - let (mut chars, mut used) = (0usize, 0usize); - // `limit` comes from a (possibly hostile) request: every count is checked, never added - // blindly, so an enormous limit cannot overflow. - let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; - while let Some(para) = body.paragraphs.get(pi) { - for (i, ch) in committed_chars(para, comp_in(comp, pi)) { - if i < at { - continue; - } - let n = ch.len_utf8(); - if !fits(used, chars, n) { - return ((pi, i), chars, used); - } - used += n; - chars += 1; - at = i + 1; - } - if pi + 1 >= body.paragraphs.len() { - break; - } - // The paragraph separator between this paragraph and the next. - if !fits(used, chars, 1) { - return ((pi, at), chars, used); - } - used += 1; - chars += 1; - pi += 1; - at = 0; - } - ((pi, at), chars, used) -} - -/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole -/// characters). Returns the position reached and the characters and bytes consumed. -fn walk_backward( - body: &TextBody, - comp: Option<(usize, usize, usize)>, - from: (usize, usize), - limit: usize, - bytes: bool, -) -> ((usize, usize), usize, usize) { - let (mut pi, mut at) = from; - let (mut chars, mut used) = (0usize, 0usize); - let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; - loop { - let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) }; - let mut boundary = at.min(para.char_len()); - for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) { - if i >= boundary { - continue; - } - let n = ch.len_utf8(); - if !fits(used, chars, n) { - return ((pi, boundary), chars, used); - } - used += n; - chars += 1; - boundary = i; - } - // This paragraph is consumed down to its start. - if pi == 0 { - return ((0, 0), chars, used); - } - if !fits(used, chars, 1) { - return ((pi, 0), chars, used); - } - used += 1; - chars += 1; - pi -= 1; - at = usize::MAX; // all of the previous paragraph is before us - } -} - -/// The committed logical text between two model positions (excluding `end`). `None` when it is -/// longer than `cap` bytes: every caller only needs a bounded answer. -fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option { - let (a, b) = if start <= end { (start, end) } else { (end, start) }; - let mut out = String::new(); - for pi in a.0..=b.0 { - let Some(para) = body.paragraphs.get(pi) else { return Some(out) }; - let from = if pi == a.0 { a.1 } else { 0 }; - let to = if pi == b.0 { b.1 } else { usize::MAX }; - for (i, ch) in committed_chars(para, comp_in(comp, pi)) { - if i < from || i >= to { - continue; - } - out.push(ch); - if out.len() > cap { - return None; - } - } - if pi < b.0 { - out.push('\n'); - if out.len() > cap { - return None; - } - } - } - Some(out) -} - -/// Committed surrounding text for the text being edited. -/// -/// Returns `None` when the body or selection is invalid, or when the selection alone is wider -/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct -/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection. -/// -/// Only a bounded window is measured and copied: the query walks at most the budget out from the -/// selection on each side and does not copy the rest of the body. Finding the selection's -/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not -/// bounded independently of the paragraph length. -pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option { - let body = body_of(st, t)?; - let comp = composition_range(body, st.composition.as_ref()); - let caret = model_pos(body, t.caret)?; - let anchor = model_pos(body, t.anchor)?; - let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) }; - let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?; - let selection_len = selected.len(); - let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len); - let (_, _, before_available) = walk_backward(body, comp, start, budget, true); - let (_, _, after_available) = walk_forward(body, comp, end, budget, true); - let mut before = (budget / 2).min(before_available); - let mut after = (budget - before).min(after_available); - before = (budget - after).min(before_available); - after = (budget - before).min(after_available); - let (window_start, _, _) = walk_backward(body, comp, start, before, true); - let (window_end, _, _) = walk_forward(body, comp, end, after, true); - let before_text = logical_between(body, comp, window_start, start, budget)?; - let after_text = logical_between(body, comp, end, window_end, budget)?; - let before_len = before_text.len(); - let mut text = before_text; - text.push_str(&selected); - text.push_str(&after_text); - // The budget arithmetic above already bounds this; refuse rather than publish more. - if text.len() > IME_SURROUNDING_MAX_BYTES { - return None; - } - // The caret and anchor are the selection's own endpoints, so their offsets inside the - // window are the window's start plus either end of the selected text. - let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) }; - Some(ImeSurrounding { text, cursor, anchor }) -} - /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?; @@ -399,1807 +188,3 @@ pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T } Ok(r) } - -/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout -/// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its -/// text. -pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> { - fit_shrink_scale(s, id)?; - let st = s.doc()?; - let Some(sh) = st.shape(id).cloned() else { return Ok(()) }; - let Some(body) = sh.text.clone() else { return Ok(()) }; - let doc = st.doc.clone(); - let sel = st.selection.clone(); - let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; - let bp = resolve::body(&ctx, &sh); - if bp.autofit != Some(AutoFit::Shape) { - return Ok(()); - } - let x = xfrm_of(&doc, &sel, &sh); - let geo = deckcraft_render::shape_geometry(&sh, x.w, x.h); - let fields = deckcraft_text::NoFields; - let mut nx = x; - if bp.wrap == Some(false) { - nx.w = deckcraft_text::fit_width(&ctx, &sh, &body, geo.text_rect, &fields).max(20.0); - } - let geo = deckcraft_render::shape_geometry(&sh, nx.w, nx.h); - let need = deckcraft_text::fit_height(&ctx, &sh, &body, geo.text_rect, &fields); - let extra = (nx.h - geo.text_rect.height()).max(0.0); - nx.h = (need + extra).max(8.0); - if (nx.h - x.h).abs() < 0.01 && (nx.w - x.w).abs() < 0.01 && sh.xfrm.is_some() { - return Ok(()); - } - // Change the box in place without an extra undo step (it is part of the edit). - let st = s.doc_mut()?; - let mut d = (*st.doc).clone(); - if let Some(list) = crate::shapes_mut(&mut d, &st.selection) - && let Some(target) = deckcraft_model::find_shape_mut(list, id) - { - target.xfrm = Some(nx); - } - st.doc = Arc::new(d); - st.revision += 1; - Ok(()) -} - -/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps -/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text -/// change: a body with no meaningful saved scale — a file's bare `` (`None`) or -/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is -/// left as it is, as PowerPoint draws it. -fn fit_shrink_scale(s: &mut Session, id: ShapeId) -> Result<()> { - let st = s.doc()?; - let Some(sh) = st.shape(id).cloned() else { return Ok(()) }; - let Some(body) = sh.text.clone() else { return Ok(()) }; - let Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) }; - if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) { - return Ok(()); - } - let doc = st.doc.clone(); - let sel = st.selection.clone(); - let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; - let x = xfrm_of(&doc, &sel, &sh); - let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect; - // Lay out as if the scale had just been reset, so the fit is computed even for `None` (which - // is drawn at 100 % and only fitted again when its text changes). - let mut probe = body.clone(); - probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); - let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }; - let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts); - if l.font_scale >= 1.0 && l.line_reduction <= 0.0 { - return Ok(()); - } - // Change the body in place without an extra undo step (it is part of the edit). - let st = s.doc_mut()?; - let mut d = (*st.doc).clone(); - let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection) - && let Some(target) = deckcraft_model::find_shape_mut(list, id) - && let Some(t) = target.text.as_mut() - && let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit - { - (*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction); - true - } else { - false - }; - if stored { - st.doc = Arc::new(d); - st.revision += 1; - } - Ok(()) -} - -pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option> { - match sel.target { - Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)), - Target::Master { master } => doc.masters.get(master).map(|m| Ctx::for_master(doc, m)), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), - } -} - -/// Lay out the text being edited (for vertical caret movement and hit testing). -pub fn layout_for(st: &DocState, t: &TextSel) -> Option { - text_frame(st, t).map(|f| f.0) -} - -/// The text being edited as it is drawn: its layout, and the transform from layout space to the -/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the -/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. -pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { - if t.notes { - return None; - } - let sh = st.shape(t.shape)?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let lay = |s: &Shape, body: &TextBody, rect: Rect| { - deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) - }; - if let Some((r, c)) = t.cell { - // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the - // table style's text look, exactly as the table draws it. - let placed = cell_on_slide(st, t.shape, r, c)?; - return Some((placed.layout, placed.to_slide)); - } - let body = sh.text.as_ref()?; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let l = lay(sh, body, tr); - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - Some((l, m)) -} - -/// The text position under slide point `p` in the text being edited (its shape or cell). -pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { - match text_frame(st, t) { - Some((l, m)) => { - let h = l.hit(m.inverse() * p); - (h.para, h.ch) - } - None => (0, 0), - } -} - -/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and -/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is -/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes, -/// which are an egui text field. -pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { - if t.notes { - return None; - } - if let Some((row, col)) = t.cell { - let placed = cell_on_slide(st, t.shape, row, col)?; - let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; - let (cx, top, bot) = placed.layout.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; - return Some((placed.to_slide * deckcraft_geom::Point::new(cx, top), placed.to_slide * deckcraft_geom::Point::new(cx, bot))); - } - let sh = st.shape(t.shape)?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let x = xfrm_of(&st.doc, &st.selection, sh); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - let mut body = sh.text.clone().unwrap_or_default(); - if body.paragraphs.is_empty() { - body.paragraphs.push(deckcraft_model::text::Paragraph::default()); - } - let l = deckcraft_text::layout( - &ctx, - sh, - &body, - &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); - // After End on a wrapped line the caret is drawn at that line's end. - let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; - let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; - let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); - Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) -} - -/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows -/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text -/// has no fill to take a colour from. -pub fn caret_color(st: &DocState, t: &TextSel) -> Option { - if t.notes || t.cell.is_some() { - return None; - } - let sh = st.shape(t.shape)?; - let body = sh.text.as_ref()?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let blank = Paragraph::default(); - let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank); - let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret)); - Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0) -} - -fn pos_param(p: &Value, key: &str) -> Option { - let a = p.get(key)?.as_array()?; - Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) -} - -fn edit(s: &mut Session, p: &Value) -> Result { - if bool_or(p, "notes", false) { - let end = s.doc()?.current_slide().map(|x| ed::end(&x.notes)).unwrap_or((0, 0)); - s.select(|_, sel| { - sel.text = Some(TextSel { notes: true, anchor: end, caret: end, ..Default::default() }); - sel.shapes.clear(); - sel.cells = None; - })?; - return ok(); - } - let id = match id_param(p, "id", "text.edit")? { - Some(i) => i, - None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, - }; - let sh = shape_of(s, id)?; - let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); - let body = match (&sh.kind, cell) { - (ShapeKind::Table(t), Some((r, c))) => t.cell(r, c).map(|x| x.text.clone()).ok_or_else(|| bad("text.edit", "no such cell"))?, - (ShapeKind::Table(_), None) => return Err(bad("text.edit", "say which `cell` of the table")), - (ShapeKind::Shape | ShapeKind::Connector { .. }, _) => sh.text.clone().unwrap_or_default(), - _ => return Err(bad("text.edit", "this object doesn't hold text")), - }; - let at = pos_param(p, "at").unwrap_or_else(|| ed::end(&body)); - // A shape without a text body gets one on first edit. - if sh.text.is_none() && cell.is_none() { - s.edit(|doc, sel| { - if let Some(list) = crate::shapes_mut(doc, sel) - && let Some(x) = deckcraft_model::find_shape_mut(list, id) - { - x.text = Some(TextBody::default()); - } - Ok(()) - })?; - } - s.select(|_, sel| { - sel.shapes = vec![id]; - sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None }); - sel.cells = None; - })?; - Ok(json!({"id": id, "at": [at.0, at.1]})) -} - -fn exit(s: &mut Session, _p: &Value) -> Result { - s.select(|_, sel| { - if let Some(t) = sel.text.take() - && !t.notes - { - sel.shapes = vec![t.shape]; - } - })?; - ok() -} - -/// Smart quotes and simple autocorrect applied to typed text. -fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String { - if !s.prefs.smart_quotes || text.chars().count() != 1 { - return text.to_string(); - } - let prev = body.paragraphs.get(at.0).and_then(|p| p.text().chars().nth(at.1.wrapping_sub(1))); - let opening = prev.is_none_or(|c| c.is_whitespace() || "([{".contains(c)); - match text { - "\"" => (if opening { "“" } else { "”" }).to_string(), - "'" => (if opening { "‘" } else { "’" }).to_string(), - _ => text.to_string(), - } -} - -pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) -> Result { - let inherited = inherited_aligns_for_edit(s); - let text = { - let st = s.doc()?; - match &st.selection.text { - Some(t) => { - let body = body_of(st, t).cloned().unwrap_or_default(); - typed(s, &body, t.ordered().0, text) - } - None => text.to_string(), - } - }; - let pos = edit_text(s, |body, t| { - let (a, b) = t.ordered(); - let at = ed::delete(body, a, b); - let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }); - let c = ed::insert(body, at, &text, props); - t.anchor = c; - t.caret = c; - let last = body.paragraphs.len().saturating_sub(1); - for pi in at.0..=c.0.min(last) { - if let Some(para) = body.paragraphs.get_mut(pi) { - promote_rtl_if_needed(para, &inherited); - } - } - c - })?; - autocorrect(s, text.chars().count() == 1)?; - // AutoCorrect can move the caret: a replaced word, or a list lead it removed. - let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret); - Ok(json!({"caret": [pos.0, pos.1]})) -} - -/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an -/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false` -/// when the shape can't be resolved (notes, a shape that is gone). -fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] { - let mut out = [false; 9]; - let Ok(st) = s.doc() else { return out }; - let Some(sh) = st.shape(id) else { return out }; - let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out }; - for (lvl, set) in out.iter_mut().enumerate() { - let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() }; - *set = resolve::para(&ctx, sh, ¶).align.is_some(); - } - out -} - -/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes). -fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] { - match s.doc().ok().and_then(|st| st.selection.text.clone()) { - Some(t) if !t.notes => inherited_aligns(s, t.shape), - _ => [false; 9], - } -} - -/// A paragraph with no direction of its own whose first strong character is right-to-left -/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the -/// paragraph or what it inherits, `inherited`) sets its alignment. -fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) { - if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { - para.props.rtl = Some(true); - let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false); - if para.props.align.is_none() && !inherits_align { - para.props.align = Some(Align::Right); - } - } -} - -/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked -/// run, the props to type with are the run's without the link. `None` elsewhere. -fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { - let mut props = ed::props_at(body, at); - props.link.as_ref()?; - let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count()); - // props_at(c + 1) is the props of the character at c. - if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link { - return None; - } - props.link = None; - Some(props) -} - -/// Built-in AutoCorrect replacements (common slips and symbols). -pub const AUTOCORRECT: &[(&str, &str)] = &[ - ("teh", "the"), - ("adn", "and"), - ("recieve", "receive"), - ("recieved", "received"), - ("seperate", "separate"), - ("occured", "occurred"), - ("occurence", "occurrence"), - ("thier", "their"), - ("wich", "which"), - ("acheive", "achieve"), - ("accross", "across"), - ("adress", "address"), - ("alot", "a lot"), - ("appearence", "appearance"), - ("arguement", "argument"), - ("basicly", "basically"), - ("beggining", "beginning"), - ("beleive", "believe"), - ("calender", "calendar"), - ("commitee", "committee"), - ("definately", "definitely"), - ("dont", "don't"), - ("doesnt", "doesn't"), - ("didnt", "didn't"), - ("isnt", "isn't"), - ("embarass", "embarrass"), - ("enviroment", "environment"), - ("existance", "existence"), - ("foward", "forward"), - ("freind", "friend"), - ("goverment", "government"), - ("happend", "happened"), - ("i", "I"), - ("ive", "I've"), - ("immediatly", "immediately"), - ("independant", "independent"), - ("knowlege", "knowledge"), - ("libary", "library"), - ("neccessary", "necessary"), - ("necesary", "necessary"), - ("noticable", "noticeable"), - ("occassion", "occasion"), - ("posession", "possession"), - ("prefered", "preferred"), - ("publically", "publicly"), - ("realy", "really"), - ("recomend", "recommend"), - ("refered", "referred"), - ("relevent", "relevant"), - ("succesful", "successful"), - ("tommorow", "tomorrow"), - ("tomorow", "tomorrow"), - ("truely", "truly"), - ("untill", "until"), - ("wierd", "weird"), - ("youre", "you're"), - ("(c)", "©"), - ("(r)", "®"), - ("(tm)", "™"), - ("(e)", "€"), - ("...", "…"), - ("->", "→"), - ("<-", "←"), - ("=>", "⇒"), - ("--", "–"), -]; - -const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; - -/// The replacement for `word` from your entries, then the built-in list. -fn replacement(prefs: &crate::Prefs, word: &str) -> Option { - if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) { - return Some(to.clone()); - } - if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) { - return Some(to.clone()); - } - let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?; - if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) { - return None; - } - // "i" only on its own, lower case. - if *k == "i" && word != "i" { - return None; - } - Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() }) -} - -/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces -/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was -/// typed (the case rules apply only then). -pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option { - let mut out = replacement(prefs, word); - if typing && out.is_none() { - let chars: Vec = word.chars().collect(); - let letters = chars.iter().all(|c| c.is_alphabetic()); - if prefs.ac_fractions { - out = match word { - "1/2" => Some("½".into()), - "1/4" => Some("¼".into()), - "3/4" => Some("¾".into()), - _ => None, - }; - } - if out.is_none() && letters && chars.len() >= 3 { - let upper = |c: &char| c.is_uppercase(); - let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase())); - let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper)); - if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower { - out = - Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::() } else { c.to_string() }).collect()); - } else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper { - out = Some( - chars - .iter() - .enumerate() - .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect() }) - .collect(), - ); - } - } - if out.is_none() && prefs.ac_days && DAYS.contains(&word) { - out = Some(ed::change_case(word, "sentence")); - } - } - if typing && prefs.ac_sentence_caps && sentence_start { - let w = out.clone().unwrap_or_else(|| word.to_string()); - let mut cs = w.chars(); - if let Some(f) = cs.next() - && f.is_lowercase() - && w.chars().all(char::is_alphabetic) - { - out = Some(f.to_uppercase().chain(cs).collect()); - } - } - out.filter(|o| o != word) -} - -/// After a space or punctuation, correct the word just typed. -fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { - if !s.prefs.autocorrect { - return Ok(()); - } - let st = s.doc()?; - let Some(t) = st.selection.text.clone() else { return Ok(()) }; - let Some(body) = body_of(st, &t) else { return Ok(()) }; - let line: Vec = body.paragraphs.get(t.caret.0).map(|p| p.text().chars().collect()).unwrap_or_default(); - let end = t.caret.1; - if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) { - return Ok(()); - } - if typing && s.prefs.ac_lists { - // A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list. - let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); - if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? { - return Ok(()); - } - } - let mut word_end = end - 1; - let mut start = word_end; - while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { - start -= 1; - } - // "word," and "word." also count: the punctuation was typed before the space. - let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); - if replacement(&s.prefs, &word).is_none() { - let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c)); - if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") { - word_end -= word.chars().count() - trimmed.chars().count(); - word = trimmed.to_string(); - } - } - let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None); - let sentence_start = before.is_none_or(|c| ".!?".contains(c)); - // "word..." becomes "word…", the word corrected too. - let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some(); - let rep = if ellipsis { - let stem = word.trim_end_matches('.'); - Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string()))) - } else { - correct_word(&s.prefs, &word, sentence_start, typing) - }; - let Some(rep) = rep else { - if typing && s.prefs.ac_ordinals && is_ordinal(&word) { - return superscript_suffix(s, &t, word_end); - } - return Ok(()); - }; - let delta = rep.chars().count() as isize - word.chars().count() as isize; - let st = s.doc_mut()?; - let mut d = (*st.doc).clone(); - let sel = st.selection.clone(); - if let Some(b) = body_mut(&mut d, &sel, &t) { - ed::delete(b, (t.caret.0, start), (t.caret.0, word_end)); - ed::insert(b, (t.caret.0, start), &rep, None); - } - st.doc = Arc::new(d); - let c = (t.caret.0, (end as isize + delta).max(0) as usize); - if let Some(tt) = st.selection.text.as_mut() { - tt.caret = c; - tt.anchor = c; - } - st.revision += 1; - Ok(()) -} - -/// A list AutoFormat started: the text being edited right after it, the typed lead with its -/// space, the paragraph's own formatting before, and the revision the change left. -#[derive(Clone, Debug)] -pub struct AutoList { - sel: TextSel, - typed: String, - props: deckcraft_model::text::ParaProps, - revision: u64, -} - -/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and -/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering -/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`. -fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> { - let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c}))); - let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme}))); - match lead { - "*" => bullet("•"), - "-" | "–" => bullet("–"), - "--" | "—" => bullet("■"), - ">" => bullet("➢"), - "<>" => bullet("◆"), - "1." => number("arabicPeriod"), - "1)" => number("arabicParenR"), - "I." => number("romanUcPeriod"), - "i." => number("romanLcPeriod"), - "A." => number("alphaUcPeriod"), - "a." => number("alphaLcPeriod"), - "a)" => number("alphaLcParenR"), - _ => None, - } -} - -/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a -/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed -/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed. -fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result { - if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') { - return Ok(false); - } - let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default(); - let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) }; - let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); - let para = t.caret.0; - let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default(); - edit_text(s, |body, tt| { - ed::delete(body, (para, 0), (para, end)); - tt.anchor = (para, 0); - tt.caret = (para, 0); - })?; - s.execute(id, ¶ms)?; - let st = s.doc_mut()?; - if let Some(sel) = st.selection.text.clone() { - st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision }); - } - Ok(true) -} - -/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and -/// space and the formatting it had. `false` when the last change was anything else. -fn undo_auto_list(s: &mut Session) -> Result { - let st = s.doc_mut()?; - let Some(a) = st.auto_list.take() else { return Ok(false) }; - if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) { - return Ok(false); - } - edit_text(s, |body, t| { - if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) { - p.props = a.props.clone(); - } - let c = ed::insert(body, a.sel.caret, &a.typed, None); - t.anchor = c; - t.caret = c; - })?; - Ok(true) -} - -/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat -/// superscripts it. -fn is_ordinal(word: &str) -> bool { - let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase()); - let suffix = word.get(digits.len()..).unwrap_or_default(); - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 { - return false; - } - let d = digits.as_bytes(); - let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1'); - let want = match d.last() { - _ if teen => "th", - Some(b'1') => "st", - Some(b'2') => "nd", - Some(b'3') => "rd", - _ => "th", - }; - suffix == want -} - -/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix). -fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> { - let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end)); - let st = s.doc_mut()?; - if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) { - return Ok(()); - } - let mut d = (*st.doc).clone(); - let sel = st.selection.clone(); - if let Some(body) = body_mut(&mut d, &sel, t) { - ed::format(body, a, b, &|r| r.baseline = Some(0.3)); - } - st.doc = Arc::new(d); - st.revision += 1; - Ok(()) -} - -fn autocorrect_options(s: &mut Session, p: &Value) -> Result { - let pr = &mut s.prefs; - for (key, slot) in [ - ("on", &mut pr.autocorrect), - ("twoInitials", &mut pr.ac_two_initials), - ("sentenceCaps", &mut pr.ac_sentence_caps), - ("days", &mut pr.ac_days), - ("capsLock", &mut pr.ac_caps_lock), - ("fractions", &mut pr.ac_fractions), - ("ordinals", &mut pr.ac_ordinals), - ("lists", &mut pr.ac_lists), - ] { - if let Some(v) = p.get(key).and_then(Value::as_bool) { - *slot = v; - } - } - if let Some(Value::Array(a)) = p.get("add") { - let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else { - return Err(bad("text.autocorrect", "`add` is [from, to]")); - }; - let from = from.trim(); - if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 { - return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255")); - } - pr.ac_entries.retain(|(k, _)| k != from); - pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from)); - if pr.ac_entries.len() >= 2000 { - return Err(bad("text.autocorrect", "the list is full")); - } - pr.ac_entries.push((from.to_string(), to.to_string())); - } - if let Some(from) = str_param(p, "remove") { - let had = pr.ac_entries.len(); - pr.ac_entries.retain(|(k, _)| k != from); - if had == pr.ac_entries.len() - && AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from)) - && !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from)) - { - pr.ac_removed.push(from.to_string()); - } - } - let list: Vec<(String, String)> = pr - .ac_entries - .iter() - .cloned() - .chain( - AUTOCORRECT - .iter() - .filter(|(k, _)| { - !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k)) - }) - .map(|(k, v)| (k.to_string(), v.to_string())), - ) - .collect(); - Ok(json!({ - "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, - "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, - "ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list, - })) -} - -pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { - let inherited = inherited_aligns_for_edit(s); - let pos = edit_text(s, |body, t| { - let (a, b) = t.ordered(); - let at = ed::delete(body, a, b); - let c = ed::insert_body(body, at, src); - t.anchor = c; - t.caret = c; - let last = body.paragraphs.len().saturating_sub(1); - for pi in at.0..=c.0.min(last) { - if let Some(para) = body.paragraphs.get_mut(pi) { - promote_rtl_if_needed(para, &inherited); - } - } - c - })?; - Ok(json!({"caret": [pos.0, pos.1]})) -} - -pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result { - let field = field.to_string(); - let text = if text.is_empty() { "#".to_string() } else { text.to_string() }; - edit_text(s, move |body, t| { - let (a, b) = t.ordered(); - let at = ed::delete(body, a, b); - let props = ed::props_at(body, at); - let src = TextBody { - paragraphs: vec![deckcraft_model::text::Paragraph { - runs: vec![Run { text: text.clone(), props, kind: RunKind::Field { field: field.clone() } }], - ..Default::default() - }], - ..Default::default() - }; - let c = ed::insert_body(body, at, &src); - t.anchor = c; - t.caret = c; - })?; - ok() -} - -/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own). -fn ime_text(s: &str) -> String { - s.chars().filter(|c| !c.is_control()).collect() -} - -/// Is the composition still where it was put (the paragraph holds all of it)? -fn composition_fits(st: &DocState, c: &crate::Composition) -> bool { - let Some(t) = &st.selection.text else { return false }; - let Some(body) = body_of(st, t) else { return false }; - body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len()) -} - -/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection) -/// or replace the composition with the new preedit. An empty preedit cancels: the presentation -/// goes back to how it was before the composition began. -fn compose(s: &mut Session, p: &Value) -> Result { - let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?); - let n = text.chars().count(); - let active = p.get("active").and_then(Value::as_array).and_then(|a| { - let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); - (x < y).then_some((x.min(n), y.min(n))) - }); - let current = s.doc()?.composition.clone(); - // A composition whose text was changed under it is kept as it is; a new one starts. - let current = match current { - Some(c) if !composition_fits(s.doc()?, &c) => { - // The text changed under the pre-edit: the app settled it, so the platform's copy - // must be cancelled too. - s.settle_composition(); - None - } - c => c, - }; - match current { - None if text.is_empty() => Ok(json!({"composing": false})), - None => { - let st = s.doc()?; - let (before, before_selection) = (st.doc.clone(), st.selection.clone()); - let (start, props) = edit_text(s, |body, t| { - let (a, b) = t.ordered(); - let at = ed::delete(body, a, b); - let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }; - let c = ed::insert(body, at, &text, props.clone()); - t.anchor = c; - t.caret = c; - (at, props) - })?; - s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection }); - Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) - } - Some(c) if text.is_empty() => { - let st = s.doc_mut()?; - st.doc = c.before; - st.selection = c.before_selection; - st.composition = None; - st.revision += 1; - Ok(json!({"composing": false})) - } - Some(c) => { - let (start, len, props) = (c.start, c.len, c.props.clone()); - edit_text(s, |body, t| { - ed::delete(body, start, (start.0, start.1 + len)); - let e = ed::insert(body, start, &text, props); - t.anchor = e; - t.caret = e; - })?; - s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c }); - Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) - } - } -} - -/// `text.commit`: the input method's result replaces the composition (or is typed at the -/// insertion point when there is none) and the whole composition becomes one undo step. -fn commit(s: &mut Session, p: &Value) -> Result { - let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?); - if s.doc()?.composition.is_some() || !text.is_empty() { - compose(s, &json!({"text": text}))?; - } - s.end_composition(); - let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default(); - Ok(json!({"caret": [caret.0, caret.1]})) -} - -/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s -/// text as laid out on the current slide: where spelling squiggles go. -pub fn range_underlines( - st: &DocState, - id: deckcraft_model::ShapeId, - ranges: &[(usize, usize, usize)], -) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { - let Some(sh) = st.shape(id) else { return vec![] }; - let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - let mut out = vec![]; - for &(para, a, b) in ranges { - for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { - out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); - } - } - out -} - -/// Like [`range_underlines`], for the text of table `id`'s cell at `row`, `col`. -pub fn cell_range_underlines( - st: &DocState, - id: deckcraft_model::ShapeId, - row: usize, - col: usize, - ranges: &[(usize, usize, usize)], -) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { - let Some(sh) = st.shape(id) else { return vec![] }; - let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { return vec![] }; - let (Some(cell), Some(ctx)) = (tb.cell(row, col), ctx_for(&st.doc, &st.selection)) else { return vec![] }; - let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); - let Some(rect) = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col).map(|(_, _, r)| r) else { - return vec![]; - }; - let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); - let tmp = deckcraft_model::Shape::table_cell(body.clone()); - let l = deckcraft_text::layout( - &ctx, - &tmp, - &body, - &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); - let x = xfrm_of(&st.doc, &st.selection, sh); - let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation); - let mut out = vec![]; - for &(para, a, b) in ranges { - for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { - out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); - } - } - out -} - -/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, -/// thick)` per line, thick under the part being converted. Empty without a composition, or for -/// notes (an egui text field draws its own composition). -pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { - let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; - if t.notes { - return vec![]; - } - if let Some((row, col)) = t.cell { - let Some(placed) = cell_on_slide(st, t.shape, row, col) else { return vec![] }; - return underline_segments(&placed.layout, placed.to_slide, c); - } - let Some(sh) = st.shape(t.shape) else { return vec![] }; - let Some(l) = layout_for(st, t) else { return vec![] }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - underline_segments(&l, m, c) -} - -fn underline_segments( - layout: &deckcraft_text::TextLayout, - to_slide: deckcraft_geom::Affine, - c: &crate::Composition, -) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { - let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; - let mut out = vec![]; - let mut add = |a: usize, b: usize, thick: bool| { - for (x0, x1, y) in layout.composition_underlines(pos(a), pos(b)) { - out.push((to_slide * deckcraft_geom::Point::new(x0, y), to_slide * deckcraft_geom::Point::new(x1, y), thick)); - } - }; - add(0, c.len, false); - if let Some((a, b)) = c.active { - add(a, b.min(c.len), true); - } - out -} - -/// A table cell's text laid out the way it is drawn, mapped into slide coordinates. -fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option { - let sh = st.shape(id)?; - let ShapeKind::Table(tb) = &sh.kind else { return None }; - let cell = tb.cell(row, col)?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let heights = super::table_drawn_heights(&st.doc, &st.selection, tb); - let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2; - let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); - if body.paragraphs.is_empty() { - body.paragraphs.push(deckcraft_model::text::Paragraph::default()); - } - let tmp = deckcraft_model::Shape::table_cell(body.clone()); - let layout = deckcraft_text::layout( - &ctx, - &tmp, - &body, - &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); - let x = xfrm_of(&st.doc, &st.selection, sh); - let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation); - Some(CellText { layout, to_slide }) -} - -struct CellText { - layout: deckcraft_text::TextLayout, - to_slide: deckcraft_geom::Affine, -} - -fn insert(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); - insert_text(s, &text, None) -} - -pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { - if dir == "backward" && undo_auto_list(s)? { - return ok(); - } - let dir = dir.to_string(); - edit_text(s, move |body, t| { - let (a, b) = t.ordered(); - let at = if dir == "wordBackward" || dir == "wordForward" { - // Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it - // (PowerPoint for Windows). - ed::delete_word(body, a, dir == "wordForward") - } else if a != b { - ed::delete(body, a, b) - } else { - let other = match dir.as_str() { - "forward" => ed::char_move(body, a, true), - _ => { - // Backspace at the start of an indented/bulleted paragraph outdents first. - if a.1 == 0 - && let Some(para) = body.paragraphs.get_mut(a.0) - && para.level > 0 - { - para.level -= 1; - t.anchor = a; - t.caret = a; - return; - } - ed::char_move(body, a, false) - } - }; - ed::delete(body, a, other) - }; - t.anchor = at; - t.caret = at; - })?; - ok() -} - -/// A `before`/`after` count from a control or input-method request. -/// -/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is -/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that -/// would delete different text than asked. -fn count_param(p: &Value, name: &str) -> usize { - p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)) -} - -/// Delete at most the requested character or byte counts around the selection or pre-edit, -/// preserving selection direction and composition boundaries and recording one undo step -/// outside composition. -fn delete_surrounding(s: &mut Session, p: &Value) -> Result { - let before = count_param(p, "before"); - let after = count_param(p, "after"); - let bytes = bool_or(p, "bytes", false); - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?; - let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?; - let comp = composition_range(body, st.composition.as_ref()); - // The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a - // paragraph separator and for a soft line break. A range may therefore cross paragraphs. - let ordered = { - let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; - let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; - if a <= b { (a, b) } else { (b, a) } - }; - // The selection (or the input method's pre-edit) stays exactly where it is. Only whole - // scalars fully inside the requested before/after ranges around it are removed. - let (start, end) = match comp { - Some((pi, cs, ce)) => ((pi, cs), (pi, ce)), - None => ordered, - }; - let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes); - let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes); - let deleted = before_chars + after_chars; - if deleted == 0 { - return Ok(json!({"deleted": 0})); - } - let before_doc = st.doc.clone(); - let before_sel = st.selection.clone(); - let composing = st.composition.is_some(); - let vertex_kind = s.tool.vertex_kind.clone(); - edit_text(s, |body, t| { - // Delete the after-range first, so the positions of the earlier range stay valid; then - // the text before the selection. - if after_chars > 0 { - ed::delete(body, end, to); - } - if before_chars > 0 { - ed::delete(body, from, start); - } - t.anchor = remap_after_delete(t.anchor, start, from, before_chars); - t.caret = remap_after_delete(t.caret, start, from, before_chars); - t.line_end = None; - })?; - if composing { - if let Some(c) = s.doc_mut()?.composition.as_mut() { - c.start = if before_chars == 0 { - start - } else if from.0 == start.0 { - (start.0, start.1 - before_chars) - } else { - from - }; - } - } else { - push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind }); - } - Ok(json!({"deleted": deleted})) -} - -/// Map a text position across the deletion of `before_chars` characters that begins at `start` -/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier -/// paragraph those paragraphs merge, so later paragraph numbers shift too. -fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) { - if before_chars == 0 || pos < start { - return pos; - } - if from.0 == start.0 { - // Only endpoints in the start paragraph move; later paragraphs keep their text. - if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos } - } else { - (from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 }) - } -} - -fn delete(s: &mut Session, p: &Value) -> Result { - delete_dir(s, str_param(p, "dir").unwrap_or("backward")) -} - -/// Is paragraph `para` right-to-left (its lines run from the right)? -fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool { - match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) { - Some(line) => line.rtl, - None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false), - } -} - -fn move_caret(s: &mut Session, p: &Value) -> Result { - use deckcraft_text::Affinity; - let to = str_param(p, "to").unwrap_or("right").to_string(); - let extend = bool_or(p, "extend", false); - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("text.move", "not editing text"))?; - let body = body_of(st, &t).cloned().unwrap_or_default(); - let layout = layout_for(st, &t); - let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 }; - let caret = t.caret; - let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream }; - // Left and right are on screen: in a right-to-left paragraph left goes forward in the text. - let rtl = para_rtl(layout.as_ref(), &body, caret.0); - let to = match (to.as_str(), rtl) { - ("left", true) => "right", - ("right", true) => "left", - ("wordLeft", true) => "wordRight", - ("wordRight", true) => "wordLeft", - (x, _) => x, - } - .to_string(); - let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 }; - let mut upstream = false; - let new = match to.as_str() { - "left" if t.is_range() && !extend => collapse_to(true), - "right" if t.is_range() && !extend => collapse_to(false), - "left" => ed::char_move(&body, caret, false), - "right" => ed::char_move(&body, caret, true), - // Ctrl+arrows with a selection go on from its start (left) or end (right). - "wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false), - "wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true), - "paraStart" => (caret.0, 0), - "paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), - "paraUp" => ed::para_up(&body, caret), - "paraDown" => ed::para_down(&body, caret), - "start" => (0, 0), - "end" => ed::end(&body), - "up" | "down" | "lineStart" | "lineEnd" => match &layout { - Some(l) => { - let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0); - let (r, a) = match to.as_str() { - "up" => l.vertical_affine(lp(caret), aff, false, x), - "down" => l.vertical_affine(lp(caret), aff, true, x), - "lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream), - // End stays on its line: after a soft wrap the caret sits at the line's end. - _ => l.line_end_caret(lp(caret), aff), - }; - upstream = a == Affinity::Upstream; - (r.para, r.ch) - } - None => match to.as_str() { - "up" => (caret.0.saturating_sub(1), caret.1), - "down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1), - "lineStart" => (caret.0, 0), - _ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), - }, - }, - other => return Err(bad("text.move", format!("unknown direction `{other}`"))), - }; - let new = (new.0.min(body.paragraphs.len().saturating_sub(1)), new.1.min(body.paragraphs.get(new.0).map(|x| x.char_len()).unwrap_or(0))); - s.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = new; - tt.line_end = if upstream { Some(new) } else { None }; - if !extend { - tt.anchor = new; - } - } - })?; - Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream})) -} - -fn select(s: &mut Session, p: &Value) -> Result { - let a = pos_param(p, "anchor").ok_or_else(|| bad("text.select", "missing `anchor`"))?; - let c = pos_param(p, "caret").unwrap_or(a); - s.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.anchor = a; - t.caret = c; - } - })?; - ok() -} - -fn select_all(s: &mut Session, _p: &Value) -> Result { - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("text.selectAll", "not editing text"))?; - let end = body_of(st, &t).map(ed::end).unwrap_or((0, 0)); - s.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.anchor = (0, 0); - t.caret = end; - } - })?; - ok() -} - -fn select_word(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("text.selectWord", "not editing text"))?; - let at = pos_param(p, "at").unwrap_or(t.caret); - let body = body_of(st, &t).cloned().unwrap_or_default(); - let (a, b) = ed::word_at(&body, at); - s.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.anchor = a; - t.caret = b; - } - })?; - ok() -} - -fn select_para(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?; - let at = pos_param(p, "at").unwrap_or(t.caret); - let body = body_of(st, &t); - // Clamp to the last paragraph so a huge index can neither overflow nor select past the body. - let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1); - let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); - // The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too). - let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) }; - s.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.anchor = (at.0, 0); - t.caret = end; - } - })?; - ok() -} - -/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them. -/// Returns whether any tab was found. -pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool { - let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); - if tabs == 0 { - return false; - } - if let Some(r) = para.runs.first_mut() { - r.text = r.text.trim_start_matches('\t').to_string(); - } - para.level = tabs.min(8) as u8; - true -} - -fn set(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string(); - let id = match id_param(p, "id", "text.set")? { - Some(i) => i, - None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, - }; - let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); - let inherited = inherited_aligns(s, id); - edit_shapes(s, &json!({"id": id}), "text.set", |sh| { - let target = match (&mut sh.kind, cell) { - (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), - (ShapeKind::Table(_), None) => None, - _ => Some(sh.text.get_or_insert_with(TextBody::default)), - }; - let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) }; - let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); - let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect(); - let mut nb = TextBody::from_text(&text); - for (i, para) in nb.paragraphs.iter_mut().enumerate() { - // Leading tabs set the outline level. - if !apply_leading_tabs(para) - && let Some((lv, pp)) = levels.get(i) - { - para.level = *lv; - para.props = pp.clone(); - } - promote_rtl_if_needed(para, &inherited); - for r in &mut para.runs { - r.props = keep.clone(); - } - } - body.paragraphs = nb.paragraphs; - Ok(()) - })?; - fit_text_box(s, id)?; - ok() -} - -fn get(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = match id_param(p, "id", "text.get")? { - Some(i) => i, - None => match &st.selection.text { - Some(t) => t.shape, - None => st.selection.shapes.first().copied().ok_or_else(|| bad("text.get", "missing `id`"))?, - }, - }; - let sh = st.shape(id).ok_or_else(|| bad("text.get", "no such shape"))?; - let t = sh.text.clone().unwrap_or_default(); - Ok(json!({"text": t.text(), "paragraphs": t.paragraphs.iter().map(|p| json!({"level": p.level, "text": p.text()})).collect::>()})) -} - -/// Apply run formatting to the text selection (or, when not editing, to all text of the -/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), -/// or sets the formatting for what is typed next at a word boundary. -pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result { - let st = s.doc()?; - if let Some(t) = st.selection.text.clone() { - let (a, b) = format_range(st, &t); - let collapsed = a == b; - edit_text(s, |body, _| { - if collapsed { - // Next typed text: put the props on an empty paragraph's end mark or keep pending. - ed::format(body, a, b, f); - } else { - ed::format(body, a, b, f); - } - })?; - if collapsed { - // Remember as pending typing props by inserting nothing: handled via end mark for - // empty paragraphs; mid-paragraph carets keep surrounding formatting. - } - return ok(); - } - let ids = st.selection.shapes.clone(); - if ids.is_empty() { - return Err(bad("format", SELECT_TEXT_OR_SHAPE)); - } - let cells = st.selection.cells; - edit_shapes(s, &json!({"ids": ids}), "format", |sh| { - let id = sh.id; - match &mut sh.kind { - ShapeKind::Table(t) => { - // Only the selected cells, when a range of them is selected. - let range = super::table::selected_range(cells, id, t); - for (r, row) in t.rows.iter_mut().enumerate() { - for (c, cell) in row.cells.iter_mut().enumerate() { - if super::table::in_range(range, r, c) { - ed::format_all(&mut cell.text, f); - } - } - } - } - _ => { - let body = sh.text.get_or_insert_with(TextBody::default); - ed::format_all(body, f); - } - } - Ok(()) - })?; - for id in ids { - fit_text_box(s, id)?; - } - ok() -} - -/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in -/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink -/// edits this same span, so the dialog reads it too. -pub fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { - let body = body_of(st, t).cloned().unwrap_or_default(); - let (mut a, mut b) = t.ordered(); - if a == b { - let (wa, wb) = ed::word_at(&body, a); - let inside = wa.1 < a.1 && a.1 < wb.1; - if inside { - a = wa; - b = wb; - // Don't include the trailing space. - let line: Vec = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default(); - while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') { - b.1 -= 1; - } - } - } - (a, b) -} - -/// Link the text being edited: the selection or the word at the caret, as `format_run` would. -/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the -/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does. -pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result { - let st = s.doc()?; - let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) }; - let (a, b) = format_range(st, &t); - if a != b { - return format_run(s, &move |rp| rp.link = Some(link.clone())); - } - if display.is_empty() { - return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`")); - } - let pos = edit_text(s, |body, t| { - let mut props = ed::props_at(body, a); - props.link = Some(link); - let c = ed::insert(body, a, display, Some(props)); - t.anchor = c; - t.caret = c; - c - })?; - Ok(json!({"caret": [pos.0, pos.1]})) -} - -/// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the -/// selected shapes). -pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) -> Result { - let st = s.doc()?; - if let Some(t) = st.selection.text.clone() { - let (a, b) = t.ordered(); - edit_text(s, |body, _| { - if body.paragraphs.is_empty() { - body.paragraphs.push(Default::default()); - } - let last = body.paragraphs.len().saturating_sub(1); - for pi in a.0..=b.0.min(last) { - if let Some(p) = body.paragraphs.get_mut(pi) { - f(p); - } - } - })?; - return ok(); - } - let ids = st.selection.shapes.clone(); - if ids.is_empty() { - return Err(bad("format", SELECT_TEXT_OR_SHAPE)); - } - let cells = st.selection.cells; - edit_shapes(s, &json!({"ids": ids}), "format", |sh| { - let id = sh.id; - match &mut sh.kind { - ShapeKind::Table(t) => { - let range = super::table::selected_range(cells, id, t); - for (r, row) in t.rows.iter_mut().enumerate() { - for (c, cell) in row.cells.iter_mut().enumerate() { - if super::table::in_range(range, r, c) { - cell.text.paragraphs.iter_mut().for_each(f); - } - } - } - } - _ => { - let body = sh.text.get_or_insert_with(TextBody::default); - if body.paragraphs.is_empty() { - body.paragraphs.push(Default::default()); - } - body.paragraphs.iter_mut().for_each(f); - } - } - Ok(()) - })?; - for id in ids { - fit_text_box(s, id)?; - } - ok() -} - -/// Apply body (text box) changes to the edited or selected shapes. -pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result { - let st = s.doc()?; - let ids: Vec = match &st.selection.text { - Some(t) if !t.notes => vec![t.shape], - _ => st.selection.shapes.clone(), - }; - if ids.is_empty() { - return Err(bad("format", "select a shape")); - } - edit_shapes(s, &json!({"ids": ids}), "format", |sh| { - f(&mut sh.text.get_or_insert_with(TextBody::default).body); - Ok(()) - })?; - for id in ids { - fit_text_box(s, id)?; - } - ok() -} - -fn fonts_used(s: &mut Session, _: &Value) -> Result { - let mut v = crate::fonts::used_fonts(&s.doc()?.doc); - v.sort_by_key(|f| f.to_lowercase()); - Ok(json!(v)) -} - -fn replace_font(s: &mut Session, p: &Value) -> Result { - let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string(); - let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string(); - if to.len() > 256 || to.chars().any(char::is_control) { - return Err(bad("font.replace", "`to` is not a font name")); - } - // Count on a copy first so a replace that finds nothing leaves no undo step. - let mut probe = (*s.doc()?.doc).clone(); - if crate::fonts::replace_font(&mut probe, &from, &to) == 0 { - return Ok(json!({"replaced": 0})); - } - let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?; - let st = s.doc_mut()?; - st.missing_fonts = crate::fonts::missing_fonts(&st.doc); - Ok(json!({"replaced": n})) -} - -#[cfg(test)] -mod rtl_promote_tests { - use deckcraft_model::text::{Align, Paragraph, Run}; - - use super::promote_rtl_if_needed; - - fn para(text: &str) -> Paragraph { - Paragraph { runs: vec![Run::new(text)], ..Default::default() } - } - - #[test] - fn right_aligns_only_when_no_alignment_is_set_or_inherited() { - let mut p = para("مرحبا"); - promote_rtl_if_needed(&mut p, &[false; 9]); - assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right))); - let mut p = para("مرحبا"); - promote_rtl_if_needed(&mut p, &[true; 9]); - assert_eq!((p.props.rtl, p.props.align), (Some(true), None)); - let mut p = para("Hello مرحبا"); - promote_rtl_if_needed(&mut p, &[false; 9]); - assert_eq!((p.props.rtl, p.props.align), (None, None)); - } -} - -#[cfg(test)] -mod ime_surrounding_tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use serde_json::json; - - use crate::Session; - - /// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id. - fn edit(s: &mut Session) -> u64 { - let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap(); - let id = r["id"].as_u64().unwrap(); - s.execute("text.edit", &json!({"id": id})).unwrap(); - id - } - - /// Test helper: return the identified shape's text, or an empty string when it is gone. - fn text(s: &Session, id: u64) -> String { - let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap()); - s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default() - } - - /// Test helper: set an in-paragraph text selection through the engine command path. - fn select(s: &mut Session, anchor: usize, caret: usize) { - s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap(); - } - - /// DeleteSurrounding removes only the requested text around the selection. The selection - /// itself and its direction stay, so the following commit replaces it. - #[test] - fn selected_text_survives_the_requested_characters_or_bytes_around_it() { - let mut s = Session::with_new(); - let id = edit(&mut s); - select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀 - s.delete_surrounding(1, 1, false).unwrap(); - assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays"); - let t = s.doc().unwrap().selection.text.clone().unwrap(); - assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - - select(&mut s, 1, 3); - s.delete_surrounding(1, 1, true).unwrap(); - assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - } - - /// A byte range removes only whole scalars fully contained in it. A count that ends inside - /// a character leaves that character whole (and undeleted), never splitting it. - #[test] - fn byte_counts_round_inward_to_whole_characters() { - let mut s = Session::with_new(); - let id = edit(&mut s); - select(&mut s, 2, 2); // after é: bytes 0..3 - s.delete_surrounding(1, 0, true).unwrap(); - assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar"); - s.delete_surrounding(2, 0, true).unwrap(); - assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - - select(&mut s, 2, 2); - s.delete_surrounding(0, 1, true).unwrap(); - assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar"); - s.delete_surrounding(0, 4, true).unwrap(); - assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji"); - } - - /// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed - /// selection, removes the model-stored pre-edit, and treats an empty body as available. - #[test] - fn surrounding_text_is_committed_logical_and_bounded() { - use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding}; - - let mut s = Session::with_new(); - let id = edit(&mut s); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8)); - - s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor"); - assert_eq!(q.text, "aé😀b"); - - s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); - s.execute("text.move", &json!({"to": "end"})).unwrap(); - s.compose_text("日本", None).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text"); - - s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available"); - - let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500); - s.execute("text.set", &json!({"id": id, "text": long})).unwrap(); - s.execute("text.move", &json!({"to": "end"})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len()); - assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window"); - } - - /// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may - /// remove one and merge the paragraphs, exactly as the published offsets say. - #[test] - fn paragraph_marks_are_deletable_logical_text() { - use super::ime_surrounding; - - let mut s = Session::with_new(); - let id = edit(&mut s); - s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap(); - // The caret at the start of the second paragraph: after "ab" and its paragraph mark. - s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); - let st = s.doc().unwrap(); - let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3)); - - // One byte before the caret is that paragraph mark. - s.delete_surrounding(1, 0, true).unwrap(); - assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge"); - let t = s.doc().unwrap().selection.text.clone().unwrap(); - assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "ab\ncd"); - - // Characters count the same mark. - s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); - s.delete_surrounding(1, 0, false).unwrap(); - assert_eq!(text(&s, id), "abcd"); - s.execute("edit.undo", &json!({})).unwrap(); - - // And from the other side: one character after the end of the first paragraph. - s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); - s.delete_surrounding(0, 1, false).unwrap(); - assert_eq!(text(&s, id), "abcd"); - } - - /// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the - /// platform must never see the model's `\u{b}`, which its own text filter would reject. - #[test] - fn soft_line_breaks_read_as_newlines() { - use super::ime_surrounding; - - let mut s = Session::with_new(); - let id = edit(&mut s); - s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates - - s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); - s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); - s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap(); - assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph"); - let st = s.doc().unwrap(); - let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2)); - - // The break is one logical byte, so removing it removes one character. - s.delete_surrounding(1, 0, true).unwrap(); - assert_eq!(text(&s, id), "ab"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "a\u{b}b"); - } - - /// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a - /// different number that would delete other text than the platform asked for. - #[test] - fn out_of_range_counts_clamp_instead_of_truncating() { - let mut s = Session::with_new(); - let id = edit(&mut s); - select(&mut s, 1, 1); - s.delete_surrounding(usize::MAX, 0, false).unwrap(); - assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic"); - let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX); - assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); - assert!(expected > 0, "the count is clamped, not wrapped to nothing"); - assert_eq!(super::count_param(&json!({}), "before"), 0); - } - - /// A non-composing input-method deletion is exactly one undo step; the manual history entry - /// must not be duplicated by the generic command wrapper. - #[test] - fn a_non_composing_ime_deletion_is_one_undo_step() { - let mut s = Session::with_new(); - let id = edit(&mut s); - let undo = s.doc().unwrap().history.undo.len(); - select(&mut s, 3, 3); - s.delete_surrounding(1, 0, false).unwrap(); - assert_eq!(text(&s, id), "aéb"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - } -} - -#[cfg(test)] -mod replace_font_tests { - use serde_json::json; - - use crate::Session; - - fn fonts(s: &mut Session) -> Vec { - serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap() - } - - #[test] - fn replaces_runs_tables_notes_and_theme_and_undoes() { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); - let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone(); - s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap(); - s.execute("text.exit", &json!({})).unwrap(); - s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); - assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s)); - let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap(); - assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}"); - let used = fonts(&mut s); - assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}"); - // The theme's body font is replaced too, so placeholders follow. - s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato"); - assert!(s.doc().unwrap().doc.validate().is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue")); - } - - #[test] - fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() { - let mut s = Session::with_new(); - let undo = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0); - assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err()); - assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err()); - assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err()); - } -} - -#[cfg(test)] -mod select_paragraph_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn select_paragraph_huge_at() { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); - s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap(); - s.execute("text.edit", &json!({})).unwrap(); - let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})); - assert!(r.is_ok(), "{r:?}"); - let t = s.doc().unwrap().selection.text.clone().unwrap(); - // Clamped to the last (only) paragraph, never past the end of the body. - assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5))); - } -} diff --git a/crates/engine/src/cmd/text/autocorrect.rs b/crates/engine/src/cmd/text/autocorrect.rs new file mode 100644 index 000000000..33a4e8d62 --- /dev/null +++ b/crates/engine/src/cmd/text/autocorrect.rs @@ -0,0 +1,384 @@ +use super::*; + +/// Built-in AutoCorrect replacements (common slips and symbols). +pub const AUTOCORRECT: &[(&str, &str)] = &[ + ("teh", "the"), + ("adn", "and"), + ("recieve", "receive"), + ("recieved", "received"), + ("seperate", "separate"), + ("occured", "occurred"), + ("occurence", "occurrence"), + ("thier", "their"), + ("wich", "which"), + ("acheive", "achieve"), + ("accross", "across"), + ("adress", "address"), + ("alot", "a lot"), + ("appearence", "appearance"), + ("arguement", "argument"), + ("basicly", "basically"), + ("beggining", "beginning"), + ("beleive", "believe"), + ("calender", "calendar"), + ("commitee", "committee"), + ("definately", "definitely"), + ("dont", "don't"), + ("doesnt", "doesn't"), + ("didnt", "didn't"), + ("isnt", "isn't"), + ("embarass", "embarrass"), + ("enviroment", "environment"), + ("existance", "existence"), + ("foward", "forward"), + ("freind", "friend"), + ("goverment", "government"), + ("happend", "happened"), + ("i", "I"), + ("ive", "I've"), + ("immediatly", "immediately"), + ("independant", "independent"), + ("knowlege", "knowledge"), + ("libary", "library"), + ("neccessary", "necessary"), + ("necesary", "necessary"), + ("noticable", "noticeable"), + ("occassion", "occasion"), + ("posession", "possession"), + ("prefered", "preferred"), + ("publically", "publicly"), + ("realy", "really"), + ("recomend", "recommend"), + ("refered", "referred"), + ("relevent", "relevant"), + ("succesful", "successful"), + ("tommorow", "tomorrow"), + ("tomorow", "tomorrow"), + ("truely", "truly"), + ("untill", "until"), + ("wierd", "weird"), + ("youre", "you're"), + ("(c)", "©"), + ("(r)", "®"), + ("(tm)", "™"), + ("(e)", "€"), + ("...", "…"), + ("->", "→"), + ("<-", "←"), + ("=>", "⇒"), + ("--", "–"), +]; + +const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + +/// The replacement for `word` from your entries, then the built-in list. +fn replacement(prefs: &crate::Prefs, word: &str) -> Option { + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) { + return Some(to.clone()); + } + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) { + return Some(to.clone()); + } + let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?; + if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) { + return None; + } + // "i" only on its own, lower case. + if *k == "i" && word != "i" { + return None; + } + Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() }) +} + +/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces +/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was +/// typed (the case rules apply only then). +pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option { + let mut out = replacement(prefs, word); + if typing && out.is_none() { + let chars: Vec = word.chars().collect(); + let letters = chars.iter().all(|c| c.is_alphabetic()); + if prefs.ac_fractions { + out = match word { + "1/2" => Some("½".into()), + "1/4" => Some("¼".into()), + "3/4" => Some("¾".into()), + _ => None, + }; + } + if out.is_none() && letters && chars.len() >= 3 { + let upper = |c: &char| c.is_uppercase(); + let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase())); + let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper)); + if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower { + out = + Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::() } else { c.to_string() }).collect()); + } else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper { + out = Some( + chars + .iter() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect() }) + .collect(), + ); + } + } + if out.is_none() && prefs.ac_days && DAYS.contains(&word) { + out = Some(ed::change_case(word, "sentence")); + } + } + if typing && prefs.ac_sentence_caps && sentence_start { + let w = out.clone().unwrap_or_else(|| word.to_string()); + let mut cs = w.chars(); + if let Some(f) = cs.next() + && f.is_lowercase() + && w.chars().all(char::is_alphabetic) + { + out = Some(f.to_uppercase().chain(cs).collect()); + } + } + out.filter(|o| o != word) +} + +/// After a space or punctuation, correct the word just typed. +pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { + if !s.prefs.autocorrect { + return Ok(()); + } + let st = s.doc()?; + let Some(t) = st.selection.text.clone() else { return Ok(()) }; + let Some(body) = body_of(st, &t) else { return Ok(()) }; + let line: Vec = body.paragraphs.get(t.caret.0).map(|p| p.text().chars().collect()).unwrap_or_default(); + let end = t.caret.1; + if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) { + return Ok(()); + } + if typing && s.prefs.ac_lists { + // A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list. + let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); + if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? { + return Ok(()); + } + } + let mut word_end = end - 1; + let mut start = word_end; + while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { + start -= 1; + } + // "word," and "word." also count: the punctuation was typed before the space. + let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); + if replacement(&s.prefs, &word).is_none() { + let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c)); + if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") { + word_end -= word.chars().count() - trimmed.chars().count(); + word = trimmed.to_string(); + } + } + let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None); + let sentence_start = before.is_none_or(|c| ".!?".contains(c)); + // "word..." becomes "word…", the word corrected too. + let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some(); + let rep = if ellipsis { + let stem = word.trim_end_matches('.'); + Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string()))) + } else { + correct_word(&s.prefs, &word, sentence_start, typing) + }; + let Some(rep) = rep else { + if typing && s.prefs.ac_ordinals && is_ordinal(&word) { + return superscript_suffix(s, &t, word_end); + } + return Ok(()); + }; + let delta = rep.chars().count() as isize - word.chars().count() as isize; + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + let sel = st.selection.clone(); + if let Some(b) = body_mut(&mut d, &sel, &t) { + ed::delete(b, (t.caret.0, start), (t.caret.0, word_end)); + ed::insert(b, (t.caret.0, start), &rep, None); + } + st.doc = Arc::new(d); + let c = (t.caret.0, (end as isize + delta).max(0) as usize); + if let Some(tt) = st.selection.text.as_mut() { + tt.caret = c; + tt.anchor = c; + } + st.revision += 1; + Ok(()) +} + +/// A list AutoFormat started: the text being edited right after it, the typed lead with its +/// space, the paragraph's own formatting before, and the revision the change left. +#[derive(Clone, Debug)] +pub struct AutoList { + sel: TextSel, + typed: String, + props: deckcraft_model::text::ParaProps, + revision: u64, +} + +/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and +/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering +/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`. +fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> { + let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c}))); + let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme}))); + match lead { + "*" => bullet("•"), + "-" | "–" => bullet("–"), + "--" | "—" => bullet("■"), + ">" => bullet("➢"), + "<>" => bullet("◆"), + "1." => number("arabicPeriod"), + "1)" => number("arabicParenR"), + "I." => number("romanUcPeriod"), + "i." => number("romanLcPeriod"), + "A." => number("alphaUcPeriod"), + "a." => number("alphaLcPeriod"), + "a)" => number("alphaLcParenR"), + _ => None, + } +} + +/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a +/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed +/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed. +fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result { + if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') { + return Ok(false); + } + let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default(); + let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) }; + let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); + let para = t.caret.0; + let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default(); + edit_text(s, |body, tt| { + ed::delete(body, (para, 0), (para, end)); + tt.anchor = (para, 0); + tt.caret = (para, 0); + })?; + s.execute(id, ¶ms)?; + let st = s.doc_mut()?; + if let Some(sel) = st.selection.text.clone() { + st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision }); + } + Ok(true) +} + +/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and +/// space and the formatting it had. `false` when the last change was anything else. +pub(super) fn undo_auto_list(s: &mut Session) -> Result { + let st = s.doc_mut()?; + let Some(a) = st.auto_list.take() else { return Ok(false) }; + if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) { + return Ok(false); + } + edit_text(s, |body, t| { + if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) { + p.props = a.props.clone(); + } + let c = ed::insert(body, a.sel.caret, &a.typed, None); + t.anchor = c; + t.caret = c; + })?; + Ok(true) +} + +/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat +/// superscripts it. +fn is_ordinal(word: &str) -> bool { + let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase()); + let suffix = word.get(digits.len()..).unwrap_or_default(); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 { + return false; + } + let d = digits.as_bytes(); + let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1'); + let want = match d.last() { + _ if teen => "th", + Some(b'1') => "st", + Some(b'2') => "nd", + Some(b'3') => "rd", + _ => "th", + }; + suffix == want +} + +/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix). +fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> { + let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end)); + let st = s.doc_mut()?; + if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) { + return Ok(()); + } + let mut d = (*st.doc).clone(); + let sel = st.selection.clone(); + if let Some(body) = body_mut(&mut d, &sel, t) { + ed::format(body, a, b, &|r| r.baseline = Some(0.3)); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + +pub(super) fn autocorrect_options(s: &mut Session, p: &Value) -> Result { + let pr = &mut s.prefs; + for (key, slot) in [ + ("on", &mut pr.autocorrect), + ("twoInitials", &mut pr.ac_two_initials), + ("sentenceCaps", &mut pr.ac_sentence_caps), + ("days", &mut pr.ac_days), + ("capsLock", &mut pr.ac_caps_lock), + ("fractions", &mut pr.ac_fractions), + ("ordinals", &mut pr.ac_ordinals), + ("lists", &mut pr.ac_lists), + ] { + if let Some(v) = p.get(key).and_then(Value::as_bool) { + *slot = v; + } + } + if let Some(Value::Array(a)) = p.get("add") { + let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else { + return Err(bad("text.autocorrect", "`add` is [from, to]")); + }; + let from = from.trim(); + if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 { + return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255")); + } + pr.ac_entries.retain(|(k, _)| k != from); + pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from)); + if pr.ac_entries.len() >= 2000 { + return Err(bad("text.autocorrect", "the list is full")); + } + pr.ac_entries.push((from.to_string(), to.to_string())); + } + if let Some(from) = str_param(p, "remove") { + let had = pr.ac_entries.len(); + pr.ac_entries.retain(|(k, _)| k != from); + if had == pr.ac_entries.len() + && AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from)) + && !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from)) + { + pr.ac_removed.push(from.to_string()); + } + } + let list: Vec<(String, String)> = pr + .ac_entries + .iter() + .cloned() + .chain( + AUTOCORRECT + .iter() + .filter(|(k, _)| { + !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k)) + }) + .map(|(k, v)| (k.to_string(), v.to_string())), + ) + .collect(); + Ok(json!({ + "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, + "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, + "ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list, + })) +} diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs new file mode 100644 index 000000000..48293bda7 --- /dev/null +++ b/crates/engine/src/cmd/text/caret.rs @@ -0,0 +1,393 @@ +use super::autocorrect::undo_auto_list; +use super::editing::{inherited_aligns, insert_text, promote_rtl_if_needed}; +use super::ime::{composition_range, model_pos, walk_backward, walk_forward}; +use super::layout::{ctx_for, fit_text_box, layout_for, pos_param}; +use super::*; + +/// A table cell's text laid out the way it is drawn, mapped into slide coordinates. +pub(super) fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option { + let sh = st.shape(id)?; + let ShapeKind::Table(tb) = &sh.kind else { return None }; + let cell = tb.cell(row, col)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); + let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2; + let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let tmp = deckcraft_model::Shape::table_cell(body.clone()); + let layout = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation); + Some(CellText { layout, to_slide }) +} + +pub(super) struct CellText { + pub(super) layout: deckcraft_text::TextLayout, + pub(super) to_slide: deckcraft_geom::Affine, +} + +pub(super) fn insert(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); + insert_text(s, &text, None) +} + +pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { + if dir == "backward" && undo_auto_list(s)? { + return ok(); + } + let dir = dir.to_string(); + edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = if dir == "wordBackward" || dir == "wordForward" { + // Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it + // (PowerPoint for Windows). + ed::delete_word(body, a, dir == "wordForward") + } else if a != b { + ed::delete(body, a, b) + } else { + let other = match dir.as_str() { + "forward" => ed::char_move(body, a, true), + _ => { + // Backspace at the start of an indented/bulleted paragraph outdents first. + if a.1 == 0 + && let Some(para) = body.paragraphs.get_mut(a.0) + && para.level > 0 + { + para.level -= 1; + t.anchor = a; + t.caret = a; + return; + } + ed::char_move(body, a, false) + } + }; + ed::delete(body, a, other) + }; + t.anchor = at; + t.caret = at; + })?; + ok() +} + +/// A `before`/`after` count from a control or input-method request. +/// +/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is +/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that +/// would delete different text than asked. +pub(super) fn count_param(p: &Value, name: &str) -> usize { + p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)) +} + +/// Delete at most the requested character or byte counts around the selection or pre-edit, +/// preserving selection direction and composition boundaries and recording one undo step +/// outside composition. +pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let before = count_param(p, "before"); + let after = count_param(p, "after"); + let bytes = bool_or(p, "bytes", false); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?; + let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?; + let comp = composition_range(body, st.composition.as_ref()); + // The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a + // paragraph separator and for a soft line break. A range may therefore cross paragraphs. + let ordered = { + let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; + let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; + if a <= b { (a, b) } else { (b, a) } + }; + // The selection (or the input method's pre-edit) stays exactly where it is. Only whole + // scalars fully inside the requested before/after ranges around it are removed. + let (start, end) = match comp { + Some((pi, cs, ce)) => ((pi, cs), (pi, ce)), + None => ordered, + }; + let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes); + let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes); + let deleted = before_chars + after_chars; + if deleted == 0 { + return Ok(json!({"deleted": 0})); + } + let before_doc = st.doc.clone(); + let before_sel = st.selection.clone(); + let composing = st.composition.is_some(); + let vertex_kind = s.tool.vertex_kind.clone(); + edit_text(s, |body, t| { + // Delete the after-range first, so the positions of the earlier range stay valid; then + // the text before the selection. + if after_chars > 0 { + ed::delete(body, end, to); + } + if before_chars > 0 { + ed::delete(body, from, start); + } + t.anchor = remap_after_delete(t.anchor, start, from, before_chars); + t.caret = remap_after_delete(t.caret, start, from, before_chars); + t.line_end = None; + })?; + if composing { + if let Some(c) = s.doc_mut()?.composition.as_mut() { + c.start = if before_chars == 0 { + start + } else if from.0 == start.0 { + (start.0, start.1 - before_chars) + } else { + from + }; + } + } else { + push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind }); + } + Ok(json!({"deleted": deleted})) +} + +/// Map a text position across the deletion of `before_chars` characters that begins at `start` +/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier +/// paragraph those paragraphs merge, so later paragraph numbers shift too. +fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) { + if before_chars == 0 || pos < start { + return pos; + } + if from.0 == start.0 { + // Only endpoints in the start paragraph move; later paragraphs keep their text. + if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos } + } else { + (from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 }) + } +} + +pub(super) fn delete(s: &mut Session, p: &Value) -> Result { + delete_dir(s, str_param(p, "dir").unwrap_or("backward")) +} + +/// Is paragraph `para` right-to-left (its lines run from the right)? +fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool { + match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) { + Some(line) => line.rtl, + None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false), + } +} + +pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { + use deckcraft_text::Affinity; + let to = str_param(p, "to").unwrap_or("right").to_string(); + let extend = bool_or(p, "extend", false); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.move", "not editing text"))?; + let body = body_of(st, &t).cloned().unwrap_or_default(); + let layout = layout_for(st, &t); + let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 }; + let caret = t.caret; + let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream }; + // Left and right are on screen: in a right-to-left paragraph left goes forward in the text. + let rtl = para_rtl(layout.as_ref(), &body, caret.0); + let to = match (to.as_str(), rtl) { + ("left", true) => "right", + ("right", true) => "left", + ("wordLeft", true) => "wordRight", + ("wordRight", true) => "wordLeft", + (x, _) => x, + } + .to_string(); + let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 }; + let mut upstream = false; + let new = match to.as_str() { + "left" if t.is_range() && !extend => collapse_to(true), + "right" if t.is_range() && !extend => collapse_to(false), + "left" => ed::char_move(&body, caret, false), + "right" => ed::char_move(&body, caret, true), + // Ctrl+arrows with a selection go on from its start (left) or end (right). + "wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false), + "wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true), + "paraStart" => (caret.0, 0), + "paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), + "paraUp" => ed::para_up(&body, caret), + "paraDown" => ed::para_down(&body, caret), + "start" => (0, 0), + "end" => ed::end(&body), + "up" | "down" | "lineStart" | "lineEnd" => match &layout { + Some(l) => { + let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0); + let (r, a) = match to.as_str() { + "up" => l.vertical_affine(lp(caret), aff, false, x), + "down" => l.vertical_affine(lp(caret), aff, true, x), + "lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream), + // End stays on its line: after a soft wrap the caret sits at the line's end. + _ => l.line_end_caret(lp(caret), aff), + }; + upstream = a == Affinity::Upstream; + (r.para, r.ch) + } + None => match to.as_str() { + "up" => (caret.0.saturating_sub(1), caret.1), + "down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1), + "lineStart" => (caret.0, 0), + _ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), + }, + }, + other => return Err(bad("text.move", format!("unknown direction `{other}`"))), + }; + let new = (new.0.min(body.paragraphs.len().saturating_sub(1)), new.1.min(body.paragraphs.get(new.0).map(|x| x.char_len()).unwrap_or(0))); + s.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.caret = new; + tt.line_end = if upstream { Some(new) } else { None }; + if !extend { + tt.anchor = new; + } + } + })?; + Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream})) +} + +pub(super) fn select(s: &mut Session, p: &Value) -> Result { + let a = pos_param(p, "anchor").ok_or_else(|| bad("text.select", "missing `anchor`"))?; + let c = pos_param(p, "caret").unwrap_or(a); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = a; + t.caret = c; + } + })?; + ok() +} + +pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.selectAll", "not editing text"))?; + let end = body_of(st, &t).map(ed::end).unwrap_or((0, 0)); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = (0, 0); + t.caret = end; + } + })?; + ok() +} + +pub(super) fn select_word(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.selectWord", "not editing text"))?; + let at = pos_param(p, "at").unwrap_or(t.caret); + let body = body_of(st, &t).cloned().unwrap_or_default(); + let (a, b) = ed::word_at(&body, at); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = a; + t.caret = b; + } + })?; + ok() +} + +pub(super) fn select_para(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?; + let at = pos_param(p, "at").unwrap_or(t.caret); + let body = body_of(st, &t); + // Clamp to the last paragraph so a huge index can neither overflow nor select past the body. + let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1); + let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); + // The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too). + let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) }; + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = (at.0, 0); + t.caret = end; + } + })?; + ok() +} + +/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them. +/// Returns whether any tab was found. +pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool { + let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); + if tabs == 0 { + return false; + } + if let Some(r) = para.runs.first_mut() { + r.text = r.text.trim_start_matches('\t').to_string(); + } + para.level = tabs.min(8) as u8; + true +} + +pub(super) fn set(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string(); + let id = match id_param(p, "id", "text.set")? { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, + }; + let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + let inherited = inherited_aligns(s, id); + edit_shapes(s, &json!({"id": id}), "text.set", |sh| { + let target = match (&mut sh.kind, cell) { + (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), + (ShapeKind::Table(_), None) => None, + _ => Some(sh.text.get_or_insert_with(TextBody::default)), + }; + let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) }; + let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect(); + let mut nb = TextBody::from_text(&text); + for (i, para) in nb.paragraphs.iter_mut().enumerate() { + // Leading tabs set the outline level. + if !apply_leading_tabs(para) + && let Some((lv, pp)) = levels.get(i) + { + para.level = *lv; + para.props = pp.clone(); + } + promote_rtl_if_needed(para, &inherited); + for r in &mut para.runs { + r.props = keep.clone(); + } + } + body.paragraphs = nb.paragraphs; + Ok(()) + })?; + fit_text_box(s, id)?; + ok() +} + +pub(super) fn get(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = match id_param(p, "id", "text.get")? { + Some(i) => i, + None => match &st.selection.text { + Some(t) => t.shape, + None => st.selection.shapes.first().copied().ok_or_else(|| bad("text.get", "missing `id`"))?, + }, + }; + let sh = st.shape(id).ok_or_else(|| bad("text.get", "no such shape"))?; + let t = sh.text.clone().unwrap_or_default(); + Ok(json!({"text": t.text(), "paragraphs": t.paragraphs.iter().map(|p| json!({"level": p.level, "text": p.text()})).collect::>()})) +} + +#[cfg(test)] +mod select_paragraph_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn select_paragraph_huge_at() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap(); + s.execute("text.edit", &json!({})).unwrap(); + let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})); + assert!(r.is_ok(), "{r:?}"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + // Clamped to the last (only) paragraph, never past the end of the body. + assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5))); + } +} diff --git a/crates/engine/src/cmd/text/editing.rs b/crates/engine/src/cmd/text/editing.rs new file mode 100644 index 000000000..262b4cdda --- /dev/null +++ b/crates/engine/src/cmd/text/editing.rs @@ -0,0 +1,217 @@ +use super::autocorrect::autocorrect; +use super::layout::{ctx_for, pos_param}; +use super::*; + +pub(super) fn edit(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "notes", false) { + let end = s.doc()?.current_slide().map(|x| ed::end(&x.notes)).unwrap_or((0, 0)); + s.select(|_, sel| { + sel.text = Some(TextSel { notes: true, anchor: end, caret: end, ..Default::default() }); + sel.shapes.clear(); + sel.cells = None; + })?; + return ok(); + } + let id = match id_param(p, "id", "text.edit")? { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, + }; + let sh = shape_of(s, id)?; + let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + let body = match (&sh.kind, cell) { + (ShapeKind::Table(t), Some((r, c))) => t.cell(r, c).map(|x| x.text.clone()).ok_or_else(|| bad("text.edit", "no such cell"))?, + (ShapeKind::Table(_), None) => return Err(bad("text.edit", "say which `cell` of the table")), + (ShapeKind::Shape | ShapeKind::Connector { .. }, _) => sh.text.clone().unwrap_or_default(), + _ => return Err(bad("text.edit", "this object doesn't hold text")), + }; + let at = pos_param(p, "at").unwrap_or_else(|| ed::end(&body)); + // A shape without a text body gets one on first edit. + if sh.text.is_none() && cell.is_none() { + s.edit(|doc, sel| { + if let Some(list) = crate::shapes_mut(doc, sel) + && let Some(x) = deckcraft_model::find_shape_mut(list, id) + { + x.text = Some(TextBody::default()); + } + Ok(()) + })?; + } + s.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None }); + sel.cells = None; + })?; + Ok(json!({"id": id, "at": [at.0, at.1]})) +} + +pub(super) fn exit(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + if let Some(t) = sel.text.take() + && !t.notes + { + sel.shapes = vec![t.shape]; + } + })?; + ok() +} + +/// Smart quotes and simple autocorrect applied to typed text. +fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String { + if !s.prefs.smart_quotes || text.chars().count() != 1 { + return text.to_string(); + } + let prev = body.paragraphs.get(at.0).and_then(|p| p.text().chars().nth(at.1.wrapping_sub(1))); + let opening = prev.is_none_or(|c| c.is_whitespace() || "([{".contains(c)); + match text { + "\"" => (if opening { "“" } else { "”" }).to_string(), + "'" => (if opening { "‘" } else { "’" }).to_string(), + _ => text.to_string(), + } +} + +pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) -> Result { + let inherited = inherited_aligns_for_edit(s); + let text = { + let st = s.doc()?; + match &st.selection.text { + Some(t) => { + let body = body_of(st, t).cloned().unwrap_or_default(); + typed(s, &body, t.ordered().0, text) + } + None => text.to_string(), + } + }; + let pos = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }); + let c = ed::insert(body, at, &text, props); + t.anchor = c; + t.caret = c; + let last = body.paragraphs.len().saturating_sub(1); + for pi in at.0..=c.0.min(last) { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para, &inherited); + } + } + c + })?; + autocorrect(s, text.chars().count() == 1)?; + // AutoCorrect can move the caret: a replaced word, or a list lead it removed. + let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret); + Ok(json!({"caret": [pos.0, pos.1]})) +} + +/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an +/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false` +/// when the shape can't be resolved (notes, a shape that is gone). +pub(super) fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] { + let mut out = [false; 9]; + let Ok(st) = s.doc() else { return out }; + let Some(sh) = st.shape(id) else { return out }; + let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out }; + for (lvl, set) in out.iter_mut().enumerate() { + let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() }; + *set = resolve::para(&ctx, sh, ¶).align.is_some(); + } + out +} + +/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes). +fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] { + match s.doc().ok().and_then(|st| st.selection.text.clone()) { + Some(t) if !t.notes => inherited_aligns(s, t.shape), + _ => [false; 9], + } +} + +/// A paragraph with no direction of its own whose first strong character is right-to-left +/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the +/// paragraph or what it inherits, `inherited`) sets its alignment. +pub(super) fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) { + if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { + para.props.rtl = Some(true); + let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false); + if para.props.align.is_none() && !inherits_align { + para.props.align = Some(Align::Right); + } + } +} + +/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked +/// run, the props to type with are the run's without the link. `None` elsewhere. +pub(super) fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { + let mut props = ed::props_at(body, at); + props.link.as_ref()?; + let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count()); + // props_at(c + 1) is the props of the character at c. + if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link { + return None; + } + props.link = None; + Some(props) +} + +pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { + let inherited = inherited_aligns_for_edit(s); + let pos = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let c = ed::insert_body(body, at, src); + t.anchor = c; + t.caret = c; + let last = body.paragraphs.len().saturating_sub(1); + for pi in at.0..=c.0.min(last) { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para, &inherited); + } + } + c + })?; + Ok(json!({"caret": [pos.0, pos.1]})) +} + +pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result { + let field = field.to_string(); + let text = if text.is_empty() { "#".to_string() } else { text.to_string() }; + edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = ed::props_at(body, at); + let src = TextBody { + paragraphs: vec![deckcraft_model::text::Paragraph { + runs: vec![Run { text: text.clone(), props, kind: RunKind::Field { field: field.clone() } }], + ..Default::default() + }], + ..Default::default() + }; + let c = ed::insert_body(body, at, &src); + t.anchor = c; + t.caret = c; + })?; + ok() +} + +#[cfg(test)] +mod rtl_promote_tests { + use deckcraft_model::text::{Align, Paragraph, Run}; + + use super::promote_rtl_if_needed; + + fn para(text: &str) -> Paragraph { + Paragraph { runs: vec![Run::new(text)], ..Default::default() } + } + + #[test] + fn right_aligns_only_when_no_alignment_is_set_or_inherited() { + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right))); + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[true; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), None)); + let mut p = para("Hello مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (None, None)); + } +} diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs new file mode 100644 index 000000000..3287df94d --- /dev/null +++ b/crates/engine/src/cmd/text/formatting.rs @@ -0,0 +1,243 @@ +use super::layout::fit_text_box; +use super::*; + +/// Apply run formatting to the text selection (or, when not editing, to all text of the +/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), +/// or sets the formatting for what is typed next at a word boundary. +pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result { + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let (a, b) = format_range(st, &t); + let collapsed = a == b; + edit_text(s, |body, _| { + if collapsed { + // Next typed text: put the props on an empty paragraph's end mark or keep pending. + ed::format(body, a, b, f); + } else { + ed::format(body, a, b, f); + } + })?; + if collapsed { + // Remember as pending typing props by inserting nothing: handled via end mark for + // empty paragraphs; mid-paragraph carets keep surrounding formatting. + } + return ok(); + } + let ids = st.selection.shapes.clone(); + if ids.is_empty() { + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); + } + let cells = st.selection.cells; + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + let id = sh.id; + match &mut sh.kind { + ShapeKind::Table(t) => { + // Only the selected cells, when a range of them is selected. + let range = crate::cmd::table::selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if crate::cmd::table::in_range(range, r, c) { + ed::format_all(&mut cell.text, f); + } + } + } + } + _ => { + let body = sh.text.get_or_insert_with(TextBody::default); + ed::format_all(body, f); + } + } + Ok(()) + })?; + for id in ids { + fit_text_box(s, id)?; + } + ok() +} + +/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in +/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink +/// edits this same span, so the dialog reads it too. +pub fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { + let body = body_of(st, t).cloned().unwrap_or_default(); + let (mut a, mut b) = t.ordered(); + if a == b { + let (wa, wb) = ed::word_at(&body, a); + let inside = wa.1 < a.1 && a.1 < wb.1; + if inside { + a = wa; + b = wb; + // Don't include the trailing space. + let line: Vec = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default(); + while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') { + b.1 -= 1; + } + } + } + (a, b) +} + +/// Link the text being edited: the selection or the word at the caret, as `format_run` would. +/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the +/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does. +pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result { + let st = s.doc()?; + let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) }; + let (a, b) = format_range(st, &t); + if a != b { + return format_run(s, &move |rp| rp.link = Some(link.clone())); + } + if display.is_empty() { + return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`")); + } + let pos = edit_text(s, |body, t| { + let mut props = ed::props_at(body, a); + props.link = Some(link); + let c = ed::insert(body, a, display, Some(props)); + t.anchor = c; + t.caret = c; + c + })?; + Ok(json!({"caret": [pos.0, pos.1]})) +} + +/// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the +/// selected shapes). +pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) -> Result { + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let (a, b) = t.ordered(); + edit_text(s, |body, _| { + if body.paragraphs.is_empty() { + body.paragraphs.push(Default::default()); + } + let last = body.paragraphs.len().saturating_sub(1); + for pi in a.0..=b.0.min(last) { + if let Some(p) = body.paragraphs.get_mut(pi) { + f(p); + } + } + })?; + return ok(); + } + let ids = st.selection.shapes.clone(); + if ids.is_empty() { + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); + } + let cells = st.selection.cells; + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + let id = sh.id; + match &mut sh.kind { + ShapeKind::Table(t) => { + let range = crate::cmd::table::selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if crate::cmd::table::in_range(range, r, c) { + cell.text.paragraphs.iter_mut().for_each(f); + } + } + } + } + _ => { + let body = sh.text.get_or_insert_with(TextBody::default); + if body.paragraphs.is_empty() { + body.paragraphs.push(Default::default()); + } + body.paragraphs.iter_mut().for_each(f); + } + } + Ok(()) + })?; + for id in ids { + fit_text_box(s, id)?; + } + ok() +} + +/// Apply body (text box) changes to the edited or selected shapes. +pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result { + let st = s.doc()?; + let ids: Vec = match &st.selection.text { + Some(t) if !t.notes => vec![t.shape], + _ => st.selection.shapes.clone(), + }; + if ids.is_empty() { + return Err(bad("format", "select a shape")); + } + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + f(&mut sh.text.get_or_insert_with(TextBody::default).body); + Ok(()) + })?; + for id in ids { + fit_text_box(s, id)?; + } + ok() +} + +pub(super) fn fonts_used(s: &mut Session, _: &Value) -> Result { + let mut v = crate::fonts::used_fonts(&s.doc()?.doc); + v.sort_by_key(|f| f.to_lowercase()); + Ok(json!(v)) +} + +pub(super) fn replace_font(s: &mut Session, p: &Value) -> Result { + let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string(); + let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string(); + if to.len() > 256 || to.chars().any(char::is_control) { + return Err(bad("font.replace", "`to` is not a font name")); + } + // Count on a copy first so a replace that finds nothing leaves no undo step. + let mut probe = (*s.doc()?.doc).clone(); + if crate::fonts::replace_font(&mut probe, &from, &to) == 0 { + return Ok(json!({"replaced": 0})); + } + let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?; + let st = s.doc_mut()?; + st.missing_fonts = crate::fonts::missing_fonts(&st.doc); + Ok(json!({"replaced": n})) +} + +#[cfg(test)] +mod replace_font_tests { + use serde_json::json; + + use crate::Session; + + fn fonts(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap() + } + + #[test] + fn replaces_runs_tables_notes_and_theme_and_undoes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s)); + let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap(); + assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}"); + let used = fonts(&mut s); + assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}"); + // The theme's body font is replaced too, so placeholders follow. + s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue")); + } + + #[test] + fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() { + let mut s = Session::with_new(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/text/ime.rs b/crates/engine/src/cmd/text/ime.rs new file mode 100644 index 000000000..9ec371950 --- /dev/null +++ b/crates/engine/src/cmd/text/ime.rs @@ -0,0 +1,620 @@ +use super::caret::cell_on_slide; +use super::editing::unlinked_at_link_end; +use super::layout::{ctx_for, layout_for}; +use super::*; + +/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query. +/// +/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the +/// engine answer without copying a whole body or document. +pub const IME_SURROUNDING_MAX_BYTES: usize = 4000; + +/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8 +/// byte offsets. The current input-method composition (pre-edit) is removed from both the text +/// and the offsets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + pub text: String, + pub cursor: usize, + pub anchor: usize, +} + +/// How a soft line break reads in the IME view: a newline, like a paragraph break. +/// +/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would +/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines +/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change. +fn ime_char(ch: char) -> char { + if ch == '\u{b}' { '\n' } else { ch } +} + +/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`. +fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { + p.runs + .iter() + .flat_map(|run| run.content().chars()) + .enumerate() + .filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))) +} + +/// The same characters, newest first, without walking the paragraph from its start. +fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { + let mut starts = Vec::with_capacity(p.runs.len()); + let mut at = 0usize; + for r in &p.runs { + let n = r.char_len(); + starts.push((at, n)); + at += n; + } + p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| { + let content = r.content(); + content.chars().rev().enumerate().filter_map(move |(k, ch)| { + let i = start + n - 1 - k; + (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))) + }) + }) +} + +/// The composition's `(paragraph, start, end)` character range, when it is inside the body. +pub(super) fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> { + let c = c?; + let para = body.paragraphs.get(c.start.0)?; + let start = c.start.1.min(para.char_len()); + let end = start.saturating_add(c.len).min(para.char_len()); + (start < end).then_some((c.start.0, start, end)) +} + +/// Return the composition character range only when its paragraph index equals `pi`. +fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> { + comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e)) +} + +/// Clip a model position to the body. An empty body only has position `(0, 0)`. +pub(super) fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> { + if body.paragraphs.is_empty() { + return (pos == (0, 0)).then_some((0, 0)); + } + body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len()))) +} + +/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole +/// characters), stopping at the end of the body. Returns the position reached and the characters +/// and bytes consumed. +/// +/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks +/// that paragraph's runs from their start: runs do not cache character lengths, and a position is +/// a character index. A megabyte paragraph therefore costs time proportional to the caret's +/// character offset, even though the copied surrounding text stays within `limit`. +pub(super) fn walk_forward( + body: &TextBody, + comp: Option<(usize, usize, usize)>, + from: (usize, usize), + limit: usize, + bytes: bool, +) -> ((usize, usize), usize, usize) { + let (mut pi, mut at) = from; + let (mut chars, mut used) = (0usize, 0usize); + // `limit` comes from a (possibly hostile) request: every count is checked, never added + // blindly, so an enormous limit cannot overflow. + let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; + while let Some(para) = body.paragraphs.get(pi) { + for (i, ch) in committed_chars(para, comp_in(comp, pi)) { + if i < at { + continue; + } + let n = ch.len_utf8(); + if !fits(used, chars, n) { + return ((pi, i), chars, used); + } + used += n; + chars += 1; + at = i + 1; + } + if pi + 1 >= body.paragraphs.len() { + break; + } + // The paragraph separator between this paragraph and the next. + if !fits(used, chars, 1) { + return ((pi, at), chars, used); + } + used += 1; + chars += 1; + pi += 1; + at = 0; + } + ((pi, at), chars, used) +} + +/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole +/// characters). Returns the position reached and the characters and bytes consumed. +pub(super) fn walk_backward( + body: &TextBody, + comp: Option<(usize, usize, usize)>, + from: (usize, usize), + limit: usize, + bytes: bool, +) -> ((usize, usize), usize, usize) { + let (mut pi, mut at) = from; + let (mut chars, mut used) = (0usize, 0usize); + let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; + loop { + let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) }; + let mut boundary = at.min(para.char_len()); + for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) { + if i >= boundary { + continue; + } + let n = ch.len_utf8(); + if !fits(used, chars, n) { + return ((pi, boundary), chars, used); + } + used += n; + chars += 1; + boundary = i; + } + // This paragraph is consumed down to its start. + if pi == 0 { + return ((0, 0), chars, used); + } + if !fits(used, chars, 1) { + return ((pi, 0), chars, used); + } + used += 1; + chars += 1; + pi -= 1; + at = usize::MAX; // all of the previous paragraph is before us + } +} + +/// The committed logical text between two model positions (excluding `end`). `None` when it is +/// longer than `cap` bytes: every caller only needs a bounded answer. +fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option { + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let mut out = String::new(); + for pi in a.0..=b.0 { + let Some(para) = body.paragraphs.get(pi) else { return Some(out) }; + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { usize::MAX }; + for (i, ch) in committed_chars(para, comp_in(comp, pi)) { + if i < from || i >= to { + continue; + } + out.push(ch); + if out.len() > cap { + return None; + } + } + if pi < b.0 { + out.push('\n'); + if out.len() > cap { + return None; + } + } + } + Some(out) +} + +/// Committed surrounding text for the text being edited. +/// +/// Returns `None` when the body or selection is invalid, or when the selection alone is wider +/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct +/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection. +/// +/// Only a bounded window is measured and copied: the query walks at most the budget out from the +/// selection on each side and does not copy the rest of the body. Finding the selection's +/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not +/// bounded independently of the paragraph length. +pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option { + let body = body_of(st, t)?; + let comp = composition_range(body, st.composition.as_ref()); + let caret = model_pos(body, t.caret)?; + let anchor = model_pos(body, t.anchor)?; + let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) }; + let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?; + let selection_len = selected.len(); + let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len); + let (_, _, before_available) = walk_backward(body, comp, start, budget, true); + let (_, _, after_available) = walk_forward(body, comp, end, budget, true); + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let (window_start, _, _) = walk_backward(body, comp, start, before, true); + let (window_end, _, _) = walk_forward(body, comp, end, after, true); + let before_text = logical_between(body, comp, window_start, start, budget)?; + let after_text = logical_between(body, comp, end, window_end, budget)?; + let before_len = before_text.len(); + let mut text = before_text; + text.push_str(&selected); + text.push_str(&after_text); + // The budget arithmetic above already bounds this; refuse rather than publish more. + if text.len() > IME_SURROUNDING_MAX_BYTES { + return None; + } + // The caret and anchor are the selection's own endpoints, so their offsets inside the + // window are the window's start plus either end of the selected text. + let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) }; + Some(ImeSurrounding { text, cursor, anchor }) +} + +/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own). +fn ime_text(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).collect() +} + +/// Is the composition still where it was put (the paragraph holds all of it)? +fn composition_fits(st: &DocState, c: &crate::Composition) -> bool { + let Some(t) = &st.selection.text else { return false }; + let Some(body) = body_of(st, t) else { return false }; + body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len()) +} + +/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection) +/// or replace the composition with the new preedit. An empty preedit cancels: the presentation +/// goes back to how it was before the composition began. +pub(super) fn compose(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?); + let n = text.chars().count(); + let active = p.get("active").and_then(Value::as_array).and_then(|a| { + let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); + (x < y).then_some((x.min(n), y.min(n))) + }); + let current = s.doc()?.composition.clone(); + // A composition whose text was changed under it is kept as it is; a new one starts. + let current = match current { + Some(c) if !composition_fits(s.doc()?, &c) => { + // The text changed under the pre-edit: the app settled it, so the platform's copy + // must be cancelled too. + s.settle_composition(); + None + } + c => c, + }; + match current { + None if text.is_empty() => Ok(json!({"composing": false})), + None => { + let st = s.doc()?; + let (before, before_selection) = (st.doc.clone(), st.selection.clone()); + let (start, props) = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }; + let c = ed::insert(body, at, &text, props.clone()); + t.anchor = c; + t.caret = c; + (at, props) + })?; + s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + Some(c) if text.is_empty() => { + let st = s.doc_mut()?; + st.doc = c.before; + st.selection = c.before_selection; + st.composition = None; + st.revision += 1; + Ok(json!({"composing": false})) + } + Some(c) => { + let (start, len, props) = (c.start, c.len, c.props.clone()); + edit_text(s, |body, t| { + ed::delete(body, start, (start.0, start.1 + len)); + let e = ed::insert(body, start, &text, props); + t.anchor = e; + t.caret = e; + })?; + s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + } +} + +/// `text.commit`: the input method's result replaces the composition (or is typed at the +/// insertion point when there is none) and the whole composition becomes one undo step. +pub(super) fn commit(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?); + if s.doc()?.composition.is_some() || !text.is_empty() { + compose(s, &json!({"text": text}))?; + } + s.end_composition(); + let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default(); + Ok(json!({"caret": [caret.0, caret.1]})) +} + +/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s +/// text as laid out on the current slide: where spelling squiggles go. +pub fn range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + +/// Like [`range_underlines`], for the text of table `id`'s cell at `row`, `col`. +pub fn cell_range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + row: usize, + col: usize, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { return vec![] }; + let (Some(cell), Some(ctx)) = (tb.cell(row, col), ctx_for(&st.doc, &st.selection)) else { return vec![] }; + let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); + let Some(rect) = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col).map(|(_, _, r)| r) else { + return vec![]; + }; + let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + let tmp = deckcraft_model::Shape::table_cell(body.clone()); + let l = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + +/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, +/// thick)` per line, thick under the part being converted. Empty without a composition, or for +/// notes (an egui text field draws its own composition). +pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { + let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; + if t.notes { + return vec![]; + } + if let Some((row, col)) = t.cell { + let Some(placed) = cell_on_slide(st, t.shape, row, col) else { return vec![] }; + return underline_segments(&placed.layout, placed.to_slide, c); + } + let Some(sh) = st.shape(t.shape) else { return vec![] }; + let Some(l) = layout_for(st, t) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + underline_segments(&l, m, c) +} + +fn underline_segments( + layout: &deckcraft_text::TextLayout, + to_slide: deckcraft_geom::Affine, + c: &crate::Composition, +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { + let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; + let mut out = vec![]; + let mut add = |a: usize, b: usize, thick: bool| { + for (x0, x1, y) in layout.composition_underlines(pos(a), pos(b)) { + out.push((to_slide * deckcraft_geom::Point::new(x0, y), to_slide * deckcraft_geom::Point::new(x1, y), thick)); + } + }; + add(0, c.len, false); + if let Some((a, b)) = c.active { + add(a, b.min(c.len), true); + } + out +} + +#[cfg(test)] +mod ime_surrounding_tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use serde_json::json; + + use crate::Session; + + /// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id. + fn edit(s: &mut Session) -> u64 { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + id + } + + /// Test helper: return the identified shape's text, or an empty string when it is gone. + fn text(s: &Session, id: u64) -> String { + let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap()); + s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default() + } + + /// Test helper: set an in-paragraph text selection through the engine command path. + fn select(s: &mut Session, anchor: usize, caret: usize) { + s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap(); + } + + /// DeleteSurrounding removes only the requested text around the selection. The selection + /// itself and its direction stay, so the following commit replaces it. + #[test] + fn selected_text_survives_the_requested_characters_or_bytes_around_it() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀 + s.delete_surrounding(1, 1, false).unwrap(); + assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + + select(&mut s, 1, 3); + s.delete_surrounding(1, 1, true).unwrap(); + assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + } + + /// A byte range removes only whole scalars fully contained in it. A count that ends inside + /// a character leaves that character whole (and undeleted), never splitting it. + #[test] + fn byte_counts_round_inward_to_whole_characters() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 2, 2); // after é: bytes 0..3 + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar"); + s.delete_surrounding(2, 0, true).unwrap(); + assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + + select(&mut s, 2, 2); + s.delete_surrounding(0, 1, true).unwrap(); + assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar"); + s.delete_surrounding(0, 4, true).unwrap(); + assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji"); + } + + /// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed + /// selection, removes the model-stored pre-edit, and treats an empty body as available. + #[test] + fn surrounding_text_is_committed_logical_and_bounded() { + use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding}; + + let mut s = Session::with_new(); + let id = edit(&mut s); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8)); + + s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor"); + assert_eq!(q.text, "aé😀b"); + + s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + s.compose_text("日本", None).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text"); + + s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available"); + + let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500); + s.execute("text.set", &json!({"id": id, "text": long})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len()); + assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window"); + } + + /// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may + /// remove one and merge the paragraphs, exactly as the published offsets say. + #[test] + fn paragraph_marks_are_deletable_logical_text() { + use super::ime_surrounding; + + let mut s = Session::with_new(); + let id = edit(&mut s); + s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap(); + // The caret at the start of the second paragraph: after "ab" and its paragraph mark. + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); + let st = s.doc().unwrap(); + let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3)); + + // One byte before the caret is that paragraph mark. + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "ab\ncd"); + + // Characters count the same mark. + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); + s.delete_surrounding(1, 0, false).unwrap(); + assert_eq!(text(&s, id), "abcd"); + s.execute("edit.undo", &json!({})).unwrap(); + + // And from the other side: one character after the end of the first paragraph. + s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); + s.delete_surrounding(0, 1, false).unwrap(); + assert_eq!(text(&s, id), "abcd"); + } + + /// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the + /// platform must never see the model's `\u{b}`, which its own text filter would reject. + #[test] + fn soft_line_breaks_read_as_newlines() { + use super::ime_surrounding; + + let mut s = Session::with_new(); + let id = edit(&mut s); + s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates + + s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap(); + assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph"); + let st = s.doc().unwrap(); + let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2)); + + // The break is one logical byte, so removing it removes one character. + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "ab"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "a\u{b}b"); + } + + /// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a + /// different number that would delete other text than the platform asked for. + #[test] + fn out_of_range_counts_clamp_instead_of_truncating() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 1, 1); + s.delete_surrounding(usize::MAX, 0, false).unwrap(); + assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic"); + let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX); + assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); + assert!(expected > 0, "the count is clamped, not wrapped to nothing"); + assert_eq!(super::count_param(&json!({}), "before"), 0); + } + + /// A non-composing input-method deletion is exactly one undo step; the manual history entry + /// must not be duplicated by the generic command wrapper. + #[test] + fn a_non_composing_ime_deletion_is_one_undo_step() { + let mut s = Session::with_new(); + let id = edit(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + select(&mut s, 3, 3); + s.delete_surrounding(1, 0, false).unwrap(); + assert_eq!(text(&s, id), "aéb"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + } +} diff --git a/crates/engine/src/cmd/text/layout.rs b/crates/engine/src/cmd/text/layout.rs new file mode 100644 index 000000000..715d5110b --- /dev/null +++ b/crates/engine/src/cmd/text/layout.rs @@ -0,0 +1,198 @@ +use super::caret::cell_on_slide; +use super::*; + +/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout +/// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its +/// text. +pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> { + fit_shrink_scale(s, id)?; + let st = s.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(()) }; + let Some(body) = sh.text.clone() else { return Ok(()) }; + let doc = st.doc.clone(); + let sel = st.selection.clone(); + let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; + let bp = resolve::body(&ctx, &sh); + if bp.autofit != Some(AutoFit::Shape) { + return Ok(()); + } + let x = xfrm_of(&doc, &sel, &sh); + let geo = deckcraft_render::shape_geometry(&sh, x.w, x.h); + let fields = deckcraft_text::NoFields; + let mut nx = x; + if bp.wrap == Some(false) { + nx.w = deckcraft_text::fit_width(&ctx, &sh, &body, geo.text_rect, &fields).max(20.0); + } + let geo = deckcraft_render::shape_geometry(&sh, nx.w, nx.h); + let need = deckcraft_text::fit_height(&ctx, &sh, &body, geo.text_rect, &fields); + let extra = (nx.h - geo.text_rect.height()).max(0.0); + nx.h = (need + extra).max(8.0); + if (nx.h - x.h).abs() < 0.01 && (nx.w - x.w).abs() < 0.01 && sh.xfrm.is_some() { + return Ok(()); + } + // Change the box in place without an extra undo step (it is part of the edit). + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + if let Some(list) = crate::shapes_mut(&mut d, &st.selection) + && let Some(target) = deckcraft_model::find_shape_mut(list, id) + { + target.xfrm = Some(nx); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + +/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps +/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text +/// change: a body with no meaningful saved scale — a file's bare `` (`None`) or +/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is +/// left as it is, as PowerPoint draws it. +fn fit_shrink_scale(s: &mut Session, id: ShapeId) -> Result<()> { + let st = s.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(()) }; + let Some(body) = sh.text.clone() else { return Ok(()) }; + let Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) }; + if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) { + return Ok(()); + } + let doc = st.doc.clone(); + let sel = st.selection.clone(); + let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; + let x = xfrm_of(&doc, &sel, &sh); + let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect; + // Lay out as if the scale had just been reset, so the fit is computed even for `None` (which + // is drawn at 100 % and only fitted again when its text changes). + let mut probe = body.clone(); + probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); + let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }; + let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts); + if l.font_scale >= 1.0 && l.line_reduction <= 0.0 { + return Ok(()); + } + // Change the body in place without an extra undo step (it is part of the edit). + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection) + && let Some(target) = deckcraft_model::find_shape_mut(list, id) + && let Some(t) = target.text.as_mut() + && let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit + { + (*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction); + true + } else { + false + }; + if stored { + st.doc = Arc::new(d); + st.revision += 1; + } + Ok(()) +} + +pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option> { + match sel.target { + Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)), + Target::Master { master } => doc.masters.get(master).map(|m| Ctx::for_master(doc, m)), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), + } +} + +/// Lay out the text being edited (for vertical caret movement and hit testing). +pub fn layout_for(st: &DocState, t: &TextSel) -> Option { + text_frame(st, t).map(|f| f.0) +} + +/// The text being edited as it is drawn: its layout, and the transform from layout space to the +/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the +/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. +pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { + if t.notes { + return None; + } + let sh = st.shape(t.shape)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let lay = |s: &Shape, body: &TextBody, rect: Rect| { + deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) + }; + if let Some((r, c)) = t.cell { + // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the + // table style's text look, exactly as the table draws it. + let placed = cell_on_slide(st, t.shape, r, c)?; + return Some((placed.layout, placed.to_slide)); + } + let body = sh.text.as_ref()?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let l = lay(sh, body, tr); + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + Some((l, m)) +} + +/// The text position under slide point `p` in the text being edited (its shape or cell). +pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { + match text_frame(st, t) { + Some((l, m)) => { + let h = l.hit(m.inverse() * p); + (h.para, h.ch) + } + None => (0, 0), + } +} + +/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and +/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is +/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes, +/// which are an egui text field. +pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { + if t.notes { + return None; + } + if let Some((row, col)) = t.cell { + let placed = cell_on_slide(st, t.shape, row, col)?; + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = placed.layout.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + return Some((placed.to_slide * deckcraft_geom::Point::new(cx, top), placed.to_slide * deckcraft_geom::Point::new(cx, bot))); + } + let sh = st.shape(t.shape)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let mut body = sh.text.clone().unwrap_or_default(); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let l = deckcraft_text::layout( + &ctx, + sh, + &body, + &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + // After End on a wrapped line the caret is drawn at that line's end. + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); + Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) +} + +/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows +/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text +/// has no fill to take a colour from. +pub fn caret_color(st: &DocState, t: &TextSel) -> Option { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let body = sh.text.as_ref()?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let blank = Paragraph::default(); + let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank); + let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret)); + Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0) +} + +pub(super) fn pos_param(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array()?; + Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) +} From 8b456dce413a4f2b89e62633c788d073d6bb2360 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:07:21 +0000 Subject: [PATCH 53/90] refactor(engine): split tools.rs into tools/{hit,pointer,drag,click,geometry}.rs --- crates/engine/src/tools.rs | 1742 +-------------------------- crates/engine/src/tools/click.rs | 219 ++++ crates/engine/src/tools/drag.rs | 319 +++++ crates/engine/src/tools/geometry.rs | 325 +++++ crates/engine/src/tools/hit.rs | 306 +++++ crates/engine/src/tools/pointer.rs | 580 +++++++++ 6 files changed, 1757 insertions(+), 1734 deletions(-) create mode 100644 crates/engine/src/tools/click.rs create mode 100644 crates/engine/src/tools/drag.rs create mode 100644 crates/engine/src/tools/geometry.rs create mode 100644 crates/engine/src/tools/hit.rs create mode 100644 crates/engine/src/tools/pointer.rs diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 2325cb19a..78c6ed35c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -11,6 +11,14 @@ use serde_json::{Value, json}; use crate::cmd::{self, xfrm_of}; use crate::{HistoryEntry, Result, Session, TextSel}; +mod click; +mod drag; +mod geometry; +mod hit; +mod pointer; + +pub use hit::{line_xfrm, resize_box, table_rows, text_pos}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PointerKind { @@ -359,1740 +367,6 @@ fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f text.body.warp_adj = adj; } -impl Session { - pub fn set_tool(&mut self, kind: ToolKind) { - self.tool.kind = kind; - self.tool.drag = None; - self.tool.preview = None; - self.tool.marquee = None; - } - - /// What's under `p` on the current slide. - pub fn hit_test(&self, p: Point, tol: f64) -> Option { - let st = self.active()?; - // In Edit Points, the edited shape's vertices come first. - if let Some(id) = self.tool.editing_points(st) - && let Some(sh) = st.shape(id) - { - if let Some((path, index)) = self.tool.edit_vertex - && let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) - { - for (h, out) in [(hout, true), (hin, false)] { - if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) { - return Some(Hit::Handle { id, path, index, out }); - } - } - } - if let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) { - return Some(Hit::Vertex { id, path, index }); - } - } - // Handles of selected shapes first. - if st.selection.text.is_none() || st.selection.shapes.len() == 1 { - for id in st.selection.shapes.iter().rev() { - let Some(sh) = st.shape(*id) else { continue }; - // A locked object shows no live handles: it can't be sized, rotated or reshaped. - if sh.locked { - continue; - } - let x = xfrm_of(&st.doc, &st.selection, sh); - if sh.is_line() { - let a = x.affine(); - for (end, q) in [(0u8, a * Point::new(0.0, 0.0)), (1u8, a * Point::new(x.w, x.h))] { - if (q - p).hypot() <= tol * 1.6 { - return Some(Hit::LineEnd { id: *id, end }); - } - } - continue; - } - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - for hd in &geo.handles { - if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { - return Some(Hit::Adjust { id: *id, index: hd.index }); - } - } - for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { - if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { - return Some(Hit::Warp { id: *id, index: hd.index }); - } - } - let (pts, rot) = box_handles(&x); - if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { - return Some(Hit::Rotate { id: *id }); - } - for (i, q) in pts.iter().enumerate() { - if (*q - p).hypot() <= tol * 1.6 { - return Some(Hit::Resize { id: *id, handle: i as u8 }); - } - } - } - } - // The frame of the text being edited: grabbing it moves the box, unless a shape in front - // of the text covers that spot (#931). - if let Some(e) = edited_box(st) - && e.movable - && edit_zone(&e.xfrm, p, tol) == EditZone::Frame - && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) - { - return Some(Hit::Frame { id: e.id }); - } - // Shapes, topmost first. - // Locked objects are still picked (to format them or type in them); they just don't move. - for sh in st.shapes().iter().rev() { - if sh.hidden { - continue; - } - if let Some(h) = hit_shape(st, sh, p, tol) { - return Some(h); - } - } - None - } - - /// Feed a pointer event to the active tool. Returns what happened (for agents). - pub fn pointer(&mut self, ev: PointerEvent) -> Result { - let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; - let p = Point::new(ev.x, ev.y); - self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); - if !(p.x.is_finite() && p.y.is_finite()) { - return Ok(Value::Null); - } - // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. - if !matches!(ev.kind, PointerKind::Move) { - self.settle_composition(); - } - match ev.kind { - PointerKind::Move => { - if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { - let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0); - let mut v = points.clone(); - v.push(p); - self.tool.path_preview = v; - self.tool.path_smooth = mode == "curve"; - self.tool.path_closing = closing; - return Ok(Value::Null); - } - self.tool.hover = self.hit_test(p, tol); - self.tool.guide_hover = - if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; - let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); - if line_tool { - self.update_sites(p, tol, None); - } else { - self.tool.sites.clear(); - self.tool.glue = None; - } - Ok(Value::Null) - } - PointerKind::Down => self.down(p, ev.mods, tol), - PointerKind::Drag => self.drag(p, ev.mods, tol), - PointerKind::Up => self.up(p, ev.mods), - PointerKind::DoubleClick => { - let before = self.text_selection(); - let r = self.double_click(p, tol)?; - self.paint_if_selection_changed(before, r) - } - PointerKind::ContextClick => self.context_click(p, tol), - PointerKind::TripleClick => { - if self.active().is_some_and(|d| d.selection.text.is_some()) { - let before = self.text_selection(); - let r = self.execute("text.selectParagraph", &json!({}))?; - self.paint_if_selection_changed(before, r) - } else { - Ok(Value::Null) - } - } - } - } - - /// Format Painter applies once a text range is selected (drag, word or paragraph click). - fn paint_text_selection(&mut self) -> Result> { - if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) { - return self.execute("format.painterApply", &json!({})).map(Some); - } - Ok(None) - } - - /// Where the text selection is: its shape, cell or notes, anchor and caret. - fn text_selection(&self) -> Option { - self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret)) - } - - /// A double or triple click paints only the range it selected, not an older one it left alone. - fn paint_if_selection_changed(&mut self, before: Option, r: Value) -> Result { - if self.text_selection() == before { - return Ok(r); - } - self.paint_text_selection().map(|a| a.unwrap_or(r)) - } - - pub(crate) fn begin(&mut self, label: &str) { - let vertex_kind = self.tool.vertex_kind.clone(); - if let Some(st) = self.active_mut() - && st.interaction.is_none() - { - st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind }); - } - } - - fn commit(&mut self) { - if let Some(st) = self.active_mut() - && let Some(i) = st.interaction.take() - && !Arc::ptr_eq(&i.doc, &st.doc) - { - crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection, vertex_kind: i.vertex_kind }); - } - } - - /// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the - /// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled. - pub(crate) fn cancel_point_drag(&mut self) -> bool { - if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) { - return false; - } - self.tool.drag = None; - if let Some(st) = self.active_mut() - && st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points") - && let Some(i) = st.interaction.take() - { - // A modifier held during the drag may have set a point type; the drag is undone, so is that. - let kind = i.vertex_kind; - if !Arc::ptr_eq(&i.doc, &st.doc) { - st.doc = i.doc; - st.selection = i.selection; - st.revision += 1; - } - self.tool.vertex_kind = kind; - } - true - } - - /// End the interaction only when it is the one `label` started. A twist must not commit a drag. - pub(crate) fn commit_named(&mut self, label: &str) { - let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); - if ours { - self.commit(); - } - } - - fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { - if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { - // Next click of a freeform: close on the start point, else add a vertex. - if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) { - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return self.finish_freeform(true); - } - points.push(p); - self.tool.path_preview = points.clone(); - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); - return Ok(Value::Null); - } - match self.tool.kind.clone() { - ToolKind::Shape { preset } if is_freeform_tool(&preset) => { - self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true }); - self.tool.path_preview = vec![p]; - self.tool.path_smooth = preset == "curve"; - self.tool.path_closing = false; - return Ok(Value::Null); - } - ToolKind::Shape { preset } if preset == TABLE_ERASER => { - // Click a border to merge the cells on either side; elsewhere does nothing. - return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(Value::Null)); - } - ToolKind::Shape { .. } | ToolKind::TextBox => { - self.tool.drag = Some(Drag::Create { start: p }); - self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); - return Ok(Value::Null); - } - ToolKind::Ink { mode, .. } => { - if mode == "eraser" { - return self.erase_at(p, tol); - } - let sample = self.ink_sample(p); - self.tool.drag = Some(Drag::Ink { points: vec![sample] }); - self.tool.ink_preview = vec![sample]; - return Ok(Value::Null); - } - ToolKind::Select => {} - } - // Clicking inside the text being edited moves the caret / starts a text selection. - let before = self.text_selection(); - if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { - let press = self.doc()?.selection.text.clone(); - self.tool.drag = Some(Drag::TextSelect { shape, before, press }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } - let hit = self.hit_test(p, tol); - // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; - // a click anywhere else leaves Edit Points. - if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { - if let Some(Hit::Vertex { id, path, index }) = hit { - if mods.cmd { - return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); - } - self.tool.edit_vertex = Some((path, index)); - self.begin("Edit Points"); - self.tool.drag = Some(Drag::Vertex { id, path, index }); - return Ok(json!({"vertex": [path, index]})); - } - if let Some(Hit::Handle { id, path, index, out }) = hit { - self.begin("Edit Points"); - self.tool.drag = Some(Drag::Handle { id, path, index, out }); - return Ok(json!({"handle": [path, index]})); - } - let st = self.doc()?; - if let Some(sh) = st.shape(eid) - && let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6) - { - if mods.cmd { - return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t})); - } - return Ok(Value::Null); - } - self.tool.edit_points = None; - self.tool.edit_vertex = None; - } - let st = self.doc()?; - match hit { - Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, - Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, - Some(Hit::Resize { id, handle }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - self.begin("Size"); - self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p }); - } - Some(Hit::LineEnd { id, end }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - self.begin("Size"); - self.tool.drag = Some(Drag::LineEnd { id, end, orig }); - } - Some(Hit::Rotate { id }) => { - let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; - let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); - let center = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s).center()).unwrap_or(p); - let start = (p.y - center.y).atan2(p.x - center.x); - self.begin("Rotate"); - self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); - } - // Only reached outside Edit Points (handled above). - Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} - Some(Hit::Adjust { id, index }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - self.begin("Adjust Shape"); - self.tool.drag = Some(Drag::Adjust { id, index, orig }); - } - Some(Hit::Frame { id }) => { - // Like PowerPoint: the box leaves text editing and is selected whole (a click - // without a drag stops there), and dragging moves it as one undo step. - let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); - self.select(|_, sel| { - sel.shapes = vec![id]; - sel.text = None; - sel.cells = None; - })?; - self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); - } - Some(Hit::Warp { id, index }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - let found = st.shape(id).and_then(|sh| { - let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; - let bp = deckcraft_model::resolve::body(&ctx, sh); - bp.warp.map(|n| (n, bp.warp_adj)) - }); - if let Some((name, base)) = found { - self.begin("Adjust WordArt"); - self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); - } - } - Some(Hit::Shape { id, .. }) - if !(mods.shift || mods.cmd) - && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) - && in_table_cell(st, id, p) => - { - // A press in a table whose cells are selected starts again in the cell under the - // pointer: a click puts the caret there and a drag selects another range. A press - // on the table outside its cells selects and moves the table like any shape. - let before = self.text_selection(); - if self.edit_text_at(id, p)? { - self.select(|_, sel| sel.cells = None)?; - let press = self.doc()?.selection.text.clone(); - self.tool.drag = Some(Drag::TextSelect { shape: id, before, press }); - } - } - Some(Hit::Shape { id, text }) => { - let selected = st.selection.shapes.contains(&id); - let toggle = mods.shift || mods.cmd; - let ids: Vec = if toggle { - let mut v = st.selection.shapes.clone(); - if selected { - v.retain(|x| *x != id); - } else { - v.push(id); - } - v - } else if selected { - st.selection.shapes.clone() - } else { - vec![id] - }; - // Locked objects stay put when the selection is dragged. - let origs: Vec<(ShapeId, Xfrm)> = - ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); - // A click (no drag) on text puts the caret there; unselected text boxes and - // placeholders take the caret straight away like PowerPoint. A click in a cell of a - // selected table puts the caret there; a click on its grab band keeps the table selected. - let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); - // A click on the frame band of a text box selects the box whole, as PowerPoint - // does, so it can be moved, duplicated or deleted; only the inside takes the caret. - let on_frame = text - && is_text_first(st, id) - && st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame); - let enter_text = - (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected && in_table_cell(st, id, p)))) - .then_some((id, p)); - let ids2 = ids.clone(); - self.select(|_, sel| { - sel.shapes = ids2; - sel.text = None; - sel.cells = None; - })?; - let duplicate = mods.alt && origs.len() == ids.len(); - self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text }); - // Format Painter applies on click. - if self.painter.is_some() && !toggle { - let _ = self.execute("format.painterApply", &json!({"ids": [id]})); - } - // Animation Painter applies on click. - if self.anim_painter.is_some() && !toggle { - let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); - } - } - None if self.guide_at(p, tol).is_some() => { - // Away from every shape a press on a guide picks the guide up. - if let Some(index) = self.guide_at(p, tol) { - let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); - self.tool.drag = Some(Drag::Guide { index }); - self.tool.guide_drag = Some((index, pos)); - } - } - None => { - let add = mods.shift || mods.cmd; - self.select(|_, sel| { - sel.text = None; - sel.cells = None; - if !add { - sel.shapes.clear(); - } - })?; - self.tool.drag = Some(Drag::Marquee { start: p, add }); - self.tool.marquee = Some(Rect::from_points(p, p)); - } - } - Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})) - } - - fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { - let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; - match drag { - Drag::Guide { index } => { - let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); - let pos = if vertical { p.x } else { p.y }; - // Guides move in whole points unless snapping is off, as in PowerPoint. - let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; - self.tool.guide_drag = Some((index, pos)); - } - Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { - let mut d = p - start; - if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { - return Ok(Value::Null); - } - if !moved { - self.begin(if duplicate { "Duplicate" } else { "Move" }); - if duplicate { - // Alt-drag: drag a copy, leave the original. - self.execute_dup_in_place(&ids)?; - } - } - let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() }; - if mods.shift { - if d.x.abs() > d.y.abs() { - d.y = 0.0; - } else { - d.x = 0.0; - } - } - // Smart guides on the moved group's bounds. - let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default(); - let moved_b = bounds + d; - let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd); - d += snap; - self.tool.guides = guides; - let origs2: Vec<(ShapeId, Xfrm)> = - if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() }; - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?; - for (id, o) in &origs2 { - if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) { - sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); - } - } - // A connector dragged away from shapes that stay put comes unglued at those ends. - let moved: Vec = origs2.iter().map(|o| o.0).collect(); - for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) { - if let ShapeKind::Connector { start, end } = &mut sh.kind { - for c in [start, end] { - if c.is_some_and(|(to, _)| !moved.contains(&to)) { - *c = None; - } - } - } - } - Ok(()) - })?; - self.tool.drag = - Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); - } - Drag::Resize { id, handle, orig, start } => { - let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); - let nx = self.snap_resize(nx, &orig, handle, id, tol); - self.set_xfrm(id, nx)?; - } - Drag::LineEnd { id, end, orig } => { - let a = orig.affine(); - let (p0, p1) = (a * Point::new(0.0, 0.0), a * Point::new(orig.w, orig.h)); - let mut q = p; - let other = if end == 0 { p1 } else { p0 }; - if mods.shift { - q = constrain_45(other, q); - } - let glue = self.update_sites(q, tol, Some(id)); - if let Some((_, _, at)) = glue { - q = at; - } - let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) }; - self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?; - self.edit(|doc, sel| { - if let Some(list) = crate::shapes_mut(doc, sel) { - crate::connect::set_glue(list, id, end, glue.map(|g| (g.0, g.1))); - } - Ok(()) - })?; - } - Drag::Rotate { ids, center, start, origs } => { - let ang = (p.y - center.y).atan2(p.x - center.x); - let mut delta = (ang - start).to_degrees(); - for (id, o) in &origs { - let mut r = o.rot + delta; - if mods.shift { - r = (r / 15.0).round() * 15.0; - delta = r - o.rot; - } - let mut nx = *o; - nx.rot = ((r % 360.0) + 360.0) % 360.0; - if ids.len() > 1 { - // Rotate positions around the common centre too. - let c = o.center(); - let rc = Affine::translate(center.to_vec2()) * Affine::rotate(delta.to_radians()) * Affine::translate(-center.to_vec2()) * c; - nx.x = rc.x - o.w / 2.0; - nx.y = rc.y - o.h / 2.0; - } - self.set_xfrm(*id, nx)?; - } - } - Drag::Adjust { id, index, orig } => { - let st = self.doc()?; - let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; - let local = orig.affine().inverse() * p; - let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h); - let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; - let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow")); - let vals: Vec<(usize, f64)> = if free2d { - let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5; - let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5; - vec![(0, dx.round()), (1, dy.round())] - } else { - vec![(index, h.value_at(local))] - }; - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, id) - && let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom - { - let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); - for (i, v) in &vals { - while adj.len() <= *i { - let k = adj.len(); - adj.push(defaults.get(k).copied().unwrap_or(0.0)); - } - if let Some(slot) = adj.get_mut(*i) { - *slot = *v; - } - } - } - Ok(()) - })?; - } - Drag::Vertex { id, path, index } => { - self.tool.drag = Some(Drag::Vertex { id, path, index }); - self.edit_vertex(id, path, index, p)?; - return Ok(Value::Null); - } - Drag::Handle { id, path, index, out } => { - self.tool.drag = Some(Drag::Handle { id, path, index, out }); - let side = if out { "out" } else { "in" }; - // Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or - // corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt. - let held = if mods.shift { - Some("smooth") - } else if mods.cmd { - Some("straight") - } else if mods.alt { - Some("corner") - } else { - None - }; - if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) { - log::debug!("edit handle: {e}"); - } - return Ok(Value::Null); - } - Drag::Warp { id, index, name, base, orig } => { - let v = { - let st = self.doc()?; - let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; - let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; - let hs = deckcraft_geom::warp::handles(&name, &base, tr); - let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; - h.value_at(orig.affine().inverse() * p) - }; - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { - set_warp_adj(sh, &name, &base, index, v); - } - Ok(()) - })?; - } - Drag::Marquee { start, .. } => { - self.tool.marquee = Some(Rect::from_points(start, p)); - } - Drag::Create { start } => { - let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); - let x = if line { - let mut q = if mods.shift { constrain_45(start, p) } else { p }; - if let Some((_, _, at)) = self.update_sites(q, tol, None) { - q = at; - } - let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); - line_xfrm(s0, q, 0.0) - } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) - && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() - { - // The pencil inside a table draws a straight border, across or down. - let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; - let r = Rect::from_points(start, q); - Xfrm::new(r.x0, r.y0, r.width(), r.height()) - } else { - let mut r = Rect::from_points(start, p); - if mods.shift { - let s = r.width().max(r.height()); - let sx = if p.x < start.x { -s } else { s }; - let sy = if p.y < start.y { -s } else { s }; - r = Rect::from_points(start, start + Vec2::new(sx, sy)); - } - if mods.alt { - let d = p - start; - r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs()); - } - Xfrm::new(r.x0, r.y0, r.width(), r.height()) - }; - self.tool.preview = Some(x); - } - Drag::TextSelect { shape, press, .. } => { - // In a table, leaving the cell the drag started in selects whole cells, as in - // PowerPoint; coming back to that cell selects text in it again. - let st = self.doc()?; - if let Some(start) = press.as_ref().and_then(|t| t.cell) - && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) - && let Some(cell) = f.cell_at(f.local(p)) - { - if cell != start { - self.select(|_, sel| { - sel.text = None; - sel.shapes = vec![shape]; - sel.cells = Some((shape, start, cell)); - })?; - return Ok(Value::Null); - } - if st.selection.text.is_none() { - self.select(|_, sel| { - sel.cells = None; - sel.text = press; - })?; - } - } - let st = self.doc()?; - if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { - let pos = cmd::text::text_pos_at(st, t, p); - self.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.caret = pos; - } - })?; - } - } - Drag::TableBorder { id, column, index, start, size, frame, moved } => { - let local = frame.inverse() * p; - let d = if column { local.x - start.x } else { local.y - start.y }; - if !moved && d.abs() < tol.max(1.0) { - return Ok(Value::Null); - } - if !moved { - self.begin(if column { "Column Width" } else { "Row Height" }); - } - // An inner column border moves between its two columns (the table keeps its - // width); the right edge widens the last column and the table. A row's height is - // a minimum: rows still grow to fit their text. - if column { - let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); - self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; - } else { - self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; - } - self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); - } - Drag::Freeform { mode, mut points, pressed } => { - // Dragging scribbles (except for curves, which only take clicked points). - if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { - points.push(p); - } - let mut v = points.clone(); - if mode == "curve" { - v.push(p); - } - self.tool.path_preview = v; - self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); - } - Drag::Ink { mut points } => { - points.push(self.ink_sample(p)); - self.tool.ink_preview = points.clone(); - self.tool.drag = Some(Drag::Ink { points }); - } - } - Ok(Value::Null) - } - - /// Moves a vertex during a drag (one undo step for the whole drag). - fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { - let r = cmd::points::move_point_raw(self, id, path, index, p); - if let Err(e) = r { - log::debug!("edit point: {e}"); - } - Ok(()) - } - - /// Moves a control handle during a drag. `held` is the point type set by the modifier held - /// during the drag; the point keeps that type for later drags, as after `shape.pointType`. - fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result { - let kind = match held { - Some(k) => Some(k.to_string()), - None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3), - }; - let r = - cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?; - if let Some(k) = held { - self.tool.vertex_kind = Some((id, path, index, k.to_string())); - } - Ok(r) - } - - fn up(&mut self, p: Point, mods: Mods) -> Result { - let drag = self.tool.drag.take(); - self.tool.guides.clear(); - self.tool.sites.clear(); - self.tool.glue = None; - let mut out = Value::Null; - match drag { - Some(Drag::Guide { index }) => { - // Dropped off the slide: the guide is deleted. - let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); - let size = self.doc()?.doc.slide_size; - let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); - let limit = if vertical { size.width } else { size.height }; - out = if pos < 0.0 || pos > limit { - self.execute("guide.delete", &json!({"index": index}))? - } else { - self.execute("guide.move", &json!({"index": index, "pos": pos}))? - }; - } - Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { - // Plain click on text: caret at the click (in a table, in the clicked cell). - if self.edit_text_at(id, at)? { - out = json!({"editing": id}); - } - } - Some(Drag::Marquee { start, add }) => { - let r = Rect::from_points(start, p); - self.tool.marquee = None; - if r.width() > 1.0 || r.height() > 1.0 { - let st = self.doc()?; - let ids: Vec = st - .shapes() - .iter() - .filter(|s| !s.hidden && !s.locked) - .filter(|s| contains_rect(r, xfrm_of(&st.doc, &st.selection, s).bounds())) - .map(|s| s.id) - .collect(); - self.select(|_, sel| { - if add { - for i in &ids { - if !sel.shapes.contains(i) { - sel.shapes.push(*i); - } - } - } else { - sel.shapes = ids.clone(); - } - })?; - out = json!({"selected": ids}); - } - } - Some(Drag::Create { start }) => { - let kind = self.tool.kind.clone(); - let preview = self.tool.preview.take(); - let dragged = (p - start).hypot() > 2.0; - let res = match &kind { - ToolKind::Shape { preset } if preset == DRAW_TABLE => { - // Inside a table: split cells along the line. Elsewhere: a one-cell table. - if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() { - self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null) - } else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) { - self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))? - } else { - Value::Null - } - } - ToolKind::Shape { preset } => { - let line = deckcraft_geom::preset::is_line_like(preset); - let rect = match preview.filter(|_| dragged) { - Some(x) => x, - None if line => Xfrm::new(start.x, start.y, 72.0, 0.0), - None => Xfrm::new(start.x, start.y, 72.0, 72.0), - }; - let r = self.execute("shape.insert", &json!({"preset": preset, "rect": [rect.x, rect.y, rect.w, rect.h], "flip": format!("{}{}", if rect.flip_h { "h" } else { "" }, if rect.flip_v { "v" } else { "" })}))?; - if let (Some(id), Some(x)) = (r.get("id").and_then(Value::as_u64), preview.filter(|_| dragged && line)) { - let id = ShapeId(id as u32); - let _ = self.set_xfrm_cmd(id, x); - // Glue the ends that landed on connection sites. - let (p0, p1) = crate::connect::endpoints(&x); - let (g0, g1) = (self.glue_at(p0, 4.0), self.glue_at(p1, 4.0)); - if g0.is_some() || g1.is_some() { - let _ = self.edit(|doc, sel| { - if let Some(list) = crate::shapes_mut(doc, sel) { - crate::connect::set_glue(list, id, 0, g0.map(|g| (g.0, g.1))); - crate::connect::set_glue(list, id, 1, g1.map(|g| (g.0, g.1))); - } - Ok(()) - }); - } - } - r - } - ToolKind::TextBox => { - let w = preview.filter(|_| dragged).map(|x| x.w).unwrap_or(0.0); - let at = preview.filter(|_| dragged).map(|x| Point::new(x.x, x.y)).unwrap_or(start); - self.execute("insert.textBox", &json!({"rect": [at.x, at.y, w, 30.0]}))? - } - _ => Value::Null, - }; - // The table pencil stays on until you pick another tool, as in PowerPoint. - if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) { - self.tool.kind = ToolKind::Select; - } - out = res; - } - Some(Drag::Freeform { mode, points, .. }) => { - if mode == "scribble" { - // A scribble ending back at its start closes into a filled shape. - let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0); - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return self.finish_freeform(closed); - } - // Polygon / curve: keep going until double-click, Enter or a click on the start. - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return Ok(Value::Null); - } - Some(Drag::Ink { points }) => { - self.tool.ink_preview.clear(); - if points.len() > 1 { - self.begin("Ink"); - out = self.add_ink(points)?; - } - } - Some(Drag::Resize { id, .. }) => { - self.commit(); - let _ = cmd::text::fit_text_box(self, id); - self.refit_after_resize(id)?; - } - Some(Drag::TextSelect { before, .. }) => { - out = self.paint_if_selection_changed(before, out)?; - self.commit(); - } - Some(_) => self.commit(), - None => {} - } - // Any open interaction (move etc.) ends here. - self.commit(); - let _ = mods; - Ok(out) - } - - fn double_click(&mut self, p: Point, tol: f64) -> Result { - if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { - // The double-click's own click added a duplicate vertex. - while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) { - points.pop(); - } - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return self.finish_freeform(false); - } - if !matches!(self.tool.kind, ToolKind::Select) { - // Double-click while drawing: lock drawing mode off. - self.tool.sticky = false; - return Ok(Value::Null); - } - // The double-click's second press started a click (caret on release): this replaces it. - if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { - self.tool.drag = None; - } - // In the text being edited: select the word double-clicked. - if self.place_caret(p, tol, false)?.is_some() { - return self.execute("text.selectWord", &json!({})); - } - match self.hit_test(p, tol) { - Some(Hit::Shape { id, .. }) => { - let st = self.doc()?; - let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; - match &sh.kind { - ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { - // Caret at the double-click (in a table, in the clicked cell), then select - // the word there. - if !self.edit_text_at(id, p)? { - return Ok(Value::Null); - } - self.execute("text.selectWord", &json!({})) - } - ShapeKind::Group { .. } => { - // A group is a single selection unit, but its text stays editable: - // double-click reaches the text-holding member under the cursor. - match text_member_at(st, &sh, p, tol).and_then(|mid| st.shape(mid).cloned().map(|m| (mid, m))) { - Some((mid, msh)) if !msh.is_line() => { - let pos = text_pos(st, &msh, p); - self.execute("text.edit", &json!({"id": mid, "at": [pos.0, pos.1]}))?; - self.execute("text.selectWord", &json!({})) - } - _ => Ok(Value::Null), - } - } - _ => { - self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); - Ok(Value::Null) - } - } - } - _ => Ok(Value::Null), - } - } - - /// Right-button press: the selection the context menu will act on, as in PowerPoint. In the - /// box being edited, the caret and text selection stay (the menu then acts on the text). On a - /// selected shape (or its handles) the selection stays, multiple selection included; on - /// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text, - /// starts a drag or adds an undo step. - fn context_click(&mut self, p: Point, tol: f64) -> Result { - if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() { - return Ok(Value::Null); - } - let st = self.doc()?; - // The same zone in which a left press places the caret (`down`). - if let Some(t) = &st.selection.text - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) - && xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p) - { - return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); - } - let hit = self.hit_test(p, tol); - let id = match &hit { - Some( - Hit::Shape { id, .. } - | Hit::Resize { id, .. } - | Hit::Rotate { id } - | Hit::Adjust { id, .. } - | Hit::LineEnd { id, .. } - | Hit::Handle { id, .. } - | Hit::Vertex { id, .. } - | Hit::Frame { id } - | Hit::TableColumn { id, .. } - | Hit::TableRow { id, .. } - | Hit::Warp { id, .. }, - ) => Some(*id), - None => None, - }; - // Edit Points: the point or else the segment under the pointer picks the menu. - let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit { - Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None), - Some(Hit::Handle { .. }) => (None, None), - _ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)), - }); - if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { - self.select(|_, sel| { - sel.text = None; - sel.cells = None; - sel.shapes = id.into_iter().collect(); - })?; - } - if let Some((vertex, segment)) = picked { - // A control handle belongs to the picked point: its menu stays. - if !matches!(hit, Some(Hit::Handle { .. })) { - self.tool.edit_vertex = vertex; - } - self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg)); - self.tool.edit_segment_t = segment.map(|(_, _, t)| t); - } - let st = self.doc()?; - Ok(json!({ - "hit": serde_json::to_value(&hit).unwrap_or_default(), - "selected": st.selection.shapes, - "editing": st.selection.text.is_some(), - })) - } - - /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection - /// grows to it). In a table being edited, a click in another cell moves the caret into that - /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text - /// (or is on a handle or a table border). - fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { - let st = self.doc()?; - let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; - let Some(sh) = st.shape(t.shape) else { return Ok(None) }; - // A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`). - // A table cell: anywhere over the table. - let inside = match t.cell { - None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), - Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), - }; - // A shape in front of the edited text takes the click, as in PowerPoint (#931). - let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0); - if !inside - || in_front - || matches!( - self.hit_test(p, tol), - Some( - Hit::Resize { .. } - | Hit::Rotate { .. } - | Hit::Adjust { .. } - | Hit::Warp { .. } - | Hit::Frame { .. } - | Hit::TableColumn { .. } - | Hit::TableRow { .. } - ) - ) - { - return Ok(None); - } - let old_cell = t.cell; - if old_cell.is_some() - && let Some(f) = cmd::table::table_frame(st, sh) - { - t.cell = f.cell_at(f.local(p)).or(old_cell); - } - let pos = cmd::text::text_pos_at(st, &t, p); - // A selection can't span cells: a click in another cell starts afresh. - let extend = extend && t.cell == old_cell; - let cell = t.cell; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.cell = cell; - tt.caret = pos; - if !extend { - tt.anchor = pos; - } - } - })?; - Ok(Some((t.shape, pos))) - } - - /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell - /// at `p`. Returns whether editing started. - fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { - let st = self.doc()?; - let Some(sh) = st.shape(id) else { return Ok(false) }; - let params = match cmd::table::table_frame(st, sh) { - Some(f) => { - let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; - let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); - json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) - } - None => { - let pos = text_pos(st, sh, p); - json!({"id": id, "at": [pos.0, pos.1]}) - } - }; - self.execute("text.edit", ¶ms)?; - Ok(true) - } - - /// Press on a table border: dragging it resizes the column or row. Editing a cell of the - /// table goes on; otherwise the table becomes the selection. - fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { - let st = self.doc()?; - let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; - let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); - let (start, frame) = (f.local(p), f.affine); - if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) { - self.select(|_, sel| { - sel.shapes = vec![id]; - sel.text = None; - sel.cells = None; - })?; - } - self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); - Ok(()) - } - - fn is_picture(&self, id: ShapeId) -> bool { - self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) - } - - /// End the freeform being drawn: create the shape (if it has at least two points). - pub fn finish_freeform(&mut self, closed: bool) -> Result { - let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) }; - self.tool.path_preview.clear(); - self.tool.path_closing = false; - if !self.tool.sticky { - self.tool.kind = ToolKind::Select; - } - if points.len() < 2 { - return Ok(Value::Null); - } - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"})) - } - - /// The connection site within glue range of `p` (excluding shape `exclude`). - fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> { - let st = self.active()?; - crate::connect::nearest_site(&st.doc, &st.selection, st.shapes(), p, (tol * 2.0).max(6.0), None) - } - - /// Show the sites of the shape near `p` and return the site an end at `p` would glue to. - fn update_sites(&mut self, p: Point, tol: f64, exclude: Option) -> Option<(ShapeId, u32, Point)> { - let (sites, glue) = match self.active() { - Some(st) => { - let shapes = st.shapes(); - let near = crate::connect::site_shape_at(&st.doc, &st.selection, shapes, p, tol * 3.0, exclude); - let sites = near - .and_then(|id| shapes.iter().find(|s| s.id == id)) - .map(|s| crate::connect::sites(&st.doc, &st.selection, s)) - .unwrap_or_default(); - (sites, crate::connect::nearest_site(&st.doc, &st.selection, shapes, p, (tol * 2.0).max(6.0), exclude)) - } - None => (vec![], None), - }; - self.tool.sites = sites; - self.tool.glue = glue.map(|g| g.2); - glue - } - - fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> { - // Rows scale from their drawn heights: a stored height is only a minimum (often 0). - let drawn = self.active().and_then(|st| match &st.shape(id)?.kind { - ShapeKind::Table(t) => Some(table_rows(st, t)), - _ => None, - }); - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { - sh.xfrm = Some(x); - if let ShapeKind::Table(t) = &mut sh.kind { - // Tables resize their columns and rows proportionally. - let heights: Vec = match &drawn { - Some(d) if d.len() == t.rows.len() => d.clone(), - _ => t.rows.iter().map(|r| r.height).collect(), - }; - let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::()); - let kx = x.w / tw; - t.cols.iter_mut().for_each(|c| *c *= kx); - if th > 1e-6 { - let ky = x.h / th; - t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky); - } - } - } - Ok(()) - }) - } - - fn set_xfrm_cmd(&mut self, id: ShapeId, x: Xfrm) -> Result { - self.execute("shape.setBounds", &json!({"id": id, "x": x.x, "y": x.y, "w": x.w, "h": x.h}))?; - if x.flip_h || x.flip_v { - self.edit(|doc, sel| { - if let Some(sh) = crate::shapes_mut(doc, sel).and_then(|l| deckcraft_model::find_shape_mut(l, id)) - && let Some(xx) = sh.xfrm.as_mut() - { - xx.flip_h = x.flip_h; - xx.flip_v = x.flip_v; - } - Ok(()) - })?; - } - Ok(Value::Null) - } - - fn refit_after_resize(&mut self, id: ShapeId) -> Result<()> { - // A text box resized by hand keeps wrapping at its new width. - let st = self.doc()?; - if st.shape(id).is_some_and(|s| s.text_box) { - let _ = cmd::text::fit_text_box(self, id); - } - Ok(()) - } - - fn execute_dup_in_place(&mut self, ids: &[ShapeId]) -> Result<()> { - self.edit(|doc, sel| { - let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); - let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); - let boxes: Vec = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); - cmd::edit::reid("edit.duplicate", doc, &mut copies)?; - for (c, b) in copies.iter_mut().zip(boxes) { - c.xfrm = Some(b); - c.ph = None; - } - let new_ids: Vec = copies.iter().map(|c| c.id).collect(); - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("duplicate", "no slide"))?; - shapes.extend(copies); - sel.shapes = new_ids; - Ok(()) - }) - } - - /// The drawing guide within `tol` of `p` (only while guides are shown). - pub fn guide_at(&self, p: Point, tol: f64) -> Option { - if !self.tool.guides_visible { - return None; - } - let st = self.active()?; - st.doc - .guides - .iter() - .enumerate() - .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) - .filter(|(_, d)| *d <= tol.max(2.0)) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(i, _)| i) - } - - /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to - /// apply and the guides to draw. - fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { - self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3]) - } - - /// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on` - /// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that - /// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped. - fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec) { - let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) }; - if !enabled || !self.prefs.smart_guides { - return (Vec2::ZERO, vec![]); - } - let size = st.doc.slide_size; - let mut xs: Vec<(f64, Rect)> = vec![ - (0.0, Rect::new(0.0, 0.0, 0.0, size.height)), - (size.width / 2.0, Rect::new(size.width / 2.0, 0.0, size.width / 2.0, size.height)), - (size.width, Rect::new(size.width, 0.0, size.width, size.height)), - ]; - let mut ys: Vec<(f64, Rect)> = vec![ - (0.0, Rect::new(0.0, 0.0, size.width, 0.0)), - (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), - (size.height, Rect::new(0.0, size.height, size.width, size.height)), - ]; - if self.tool.guides_visible { - for g in &st.doc.guides { - if g.vertical { - xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); - } else { - ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); - } - } - } - for sh in st.shapes() { - if exclude.contains(&sh.id) || sh.hidden { - continue; - } - let b = xfrm_of(&st.doc, &st.selection, sh).bounds(); - for x in [b.x0, b.center().x, b.x1] { - xs.push((x, b)); - } - for y in [b.y0, b.center().y, b.y1] { - ys.push((y, b)); - } - } - let mut best_x: Option<(f64, f64, Rect)> = None; - for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) { - for (x, b) in &xs { - let d = x - edge; - if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { - best_x = Some((d, *x, *b)); - } - } - } - let mut best_y: Option<(f64, f64, Rect)> = None; - for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) { - for (y, b) in &ys { - let d = y - edge; - if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { - best_y = Some((d, *y, *b)); - } - } - } - let mut off = Vec2::ZERO; - let mut guides = vec![]; - if let Some((d, x, b)) = best_x { - off.x = d; - let rr = r + Vec2::new(d, 0.0); - guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) }); - } else if self.prefs.snap_to_grid && xs_on.contains(&true) { - let at = if xs_on[0] { - r.x0 - } else if xs_on[2] { - r.x1 - } else { - r.center().x - }; - off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; - } - if let Some((d, y, b)) = best_y { - off.y = d; - let rr = r + Vec2::new(0.0, d); - guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) }); - } else if self.prefs.snap_to_grid && ys_on.contains(&true) { - let at = if ys_on[0] { - r.y0 - } else if ys_on[2] { - r.y1 - } else { - r.center().y - }; - off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; - } - (off, guides) - } - - /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. - fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { - if x.rot != 0.0 { - self.tool.guides.clear(); - return x; - } - let r = x.rect(); - // Only the dragged edges move, so only they look for a line to snap to. The handles sit in - // the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge - // just flipped, the dragged handle is on the other side. - let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4)); - let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); - let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) }; - let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) }; - let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); - let mut nx = x; - if moves_left { - nx.x += off.x; - nx.w -= off.x; - } else if moves_right { - nx.w += off.x; - } - if moves_top { - nx.y += off.y; - nx.h -= off.y; - } else if moves_bottom { - nx.h += off.y; - } - nx.w = nx.w.max(0.0); - nx.h = nx.h.max(0.0); - self.tool.guides = guides; - nx - } - - /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. - fn ink_sample(&self, p: Point) -> (f64, f64, f32) { - let pressure = match self.tool.pressure { - Some(v) if v.is_finite() => v.clamp(0.0, 1.0), - _ => 1.0, - }; - (p.x, p.y, pressure) - } - - fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { - let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; - let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; - // Strokes join the slide's ink layer (one ink shape per slide). - let existing = self.doc()?.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id); - self.edit(|doc, sel| { - let id = match existing { - Some(id) => id, - None => { - let id = ShapeId(doc.alloc_id().ok_or_else(|| cmd::bad("ink.draw", cmd::NO_ID_SPACE))?); - let size = doc.slide_size; - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; - list.push(Shape { - id, - name: "Ink".into(), - xfrm: Some(Xfrm::new(0.0, 0.0, size.width, size.height)), - kind: ShapeKind::Ink { strokes: vec![] }, - ..Default::default() - }); - id - } - }; - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, id) - && let ShapeKind::Ink { strokes } = &mut sh.kind - { - strokes.push(stroke); - } - Ok(json!({"ink": id})) - }) - } - - fn erase_at(&mut self, p: Point, tol: f64) -> Result { - let st = self.doc()?; - let Some(ink) = st.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id) else { return Ok(Value::Null) }; - let before = st.doc.clone(); - let sel_before = st.selection.clone(); - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, ink) - && let ShapeKind::Ink { strokes } = &mut sh.kind - { - strokes.retain(|s| !s.points.iter().any(|(x, y, _)| (Point::new(*x, *y) - p).hypot() <= tol * 2.0 + s.width)); - } - Ok(()) - })?; - let vertex_kind = self.tool.vertex_kind.clone(); - if let Some(st) = self.active_mut() { - crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind }); - } - Ok(Value::Null) - } -} - -fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { - st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) -} - -/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. -/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is -/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing. -fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { - let ShapeKind::Group { children, child } = &sh.kind else { return None }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); - member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol) -} - -/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`. -/// `to_abs` maps this level's child space to slide space. -fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option { - let q = to_abs.inverse() * p; - for c in children.iter().rev() { - let Some(cx) = c.xfrm else { continue }; - let local = cx.affine().inverse() * q; - if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol { - continue; - } - if let ShapeKind::Group { children: gc, child: gch } = &c.kind { - let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h); - let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch); - if let Some(id) = member_at(gc, nested, p, tol) { - return Some(id); - } - } else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() { - return Some(c.id); - } - } - None -} - -/// The box whose text is being edited on the slide (not speaker notes, not a table cell). -struct EditedBox { - id: ShapeId, - xfrm: Xfrm, - /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. - movable: bool, -} - -/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its -/// list, topmost first, and inside a group that holds it, the members above it. -fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool { - if depth > 64 { - return false; - } - for s in shapes.iter().rev() { - if s.id == target { - return false; - } - if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() { - // The group holding the text: look among its members. - return shape_in_front(st, s.children(), target, p, tol, depth + 1); - } - if !s.hidden && hit_shape(st, s, p, tol).is_some() { - return true; - } - } - false -} - -fn edited_box(st: &crate::DocState) -> Option { - let t = st.selection.text.as_ref()?; - if t.notes || t.cell.is_some() { - return None; - } - let sh = st.shape(t.shape)?; - Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked }) -} - -/// Where a point falls against the box of the text being edited. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum EditZone { - /// Well inside: the caret goes there. - Inside, - /// On the frame: a band `tol` wide on either side of the box edge. - Frame, - Outside, -} - -/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside -/// small boxes the band narrows to a quarter of the box so their text stays reachable. -fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { - let l = x.affine().inverse() * p; - let (w, h) = (x.w.max(0.0), x.h.max(0.0)); - let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; - if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { - return EditZone::Outside; - } - let inner = tol.min(w / 4.0).min(h / 4.0); - if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } -} - -fn contains_rect(outer: Rect, inner: Rect) -> bool { - inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 -} - -fn constrain_45(a: Point, b: Point) -> Point { - let d = b - a; - let ang = d.y.atan2(d.x); - let snapped = (ang / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; - let len = d.hypot(); - a + Vec2::new(snapped.cos(), snapped.sin()) * len -} - -/// A line-like shape's box for endpoints `a` → `b` (flips encode direction). -pub fn line_xfrm(a: Point, b: Point, _rot: f64) -> Xfrm { - let r = Rect::from_points(a, b); - Xfrm { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rot: 0.0, flip_h: b.x < a.x, flip_v: b.y < a.y } -} - -/// New box when dragging resize handle `h` by `d` (slide space), keeping the shape's rotation. -pub fn resize_box(o: &Xfrm, h: u8, d: Vec2, keep_aspect: bool, from_center: bool) -> Xfrm { - // Work in the shape's rotated frame. - let rot = Affine::rotate(-o.rot.to_radians()); - let ld = rot * Point::new(d.x, d.y); - let (mut l, mut t, mut r, mut b) = (0.0, 0.0, o.w, o.h); - let (sx, sy) = (if o.flip_h { -1.0 } else { 1.0 }, if o.flip_v { -1.0 } else { 1.0 }); - let (dx, dy) = (ld.x * sx, ld.y * sy); - match h { - 0 => { - l += dx; - t += dy; - } - 1 => t += dy, - 2 => { - r += dx; - t += dy; - } - 3 => r += dx, - 4 => { - r += dx; - b += dy; - } - 5 => b += dy, - 6 => { - l += dx; - b += dy; - } - _ => l += dx, - } - if from_center { - // Symmetric growth about the centre. - let gw = match h { - 0 | 6 | 7 => -dx, - 2..=4 => dx, - _ => 0.0, - }; - let gh = match h { - 0..=2 => -dy, - 4..=6 => dy, - _ => 0.0, - }; - l = -gw; - r = o.w + gw; - t = -gh; - b = o.h + gh; - } - let mut w = r - l; - let mut hh = b - t; - if keep_aspect && o.w > 1e-9 && o.h > 1e-9 && !matches!(h, 1 | 3 | 5 | 7) { - let ar = o.w / o.h; - if (w / ar).abs() > hh.abs() { - hh = w.abs() / ar * hh.signum().max(if hh == 0.0 { 1.0 } else { hh.signum() }); - } else { - w = hh.abs() * ar * w.signum().max(if w == 0.0 { 1.0 } else { w.signum() }); - } - if matches!(h, 0 | 6) { - l = r - w; - } else { - r = l + w; - } - if matches!(h, 0 | 2) { - t = b - hh; - } else { - b = t + hh; - } - if from_center { - let (cx, cy) = (o.w / 2.0, o.h / 2.0); - l = cx - w / 2.0; - r = cx + w / 2.0; - t = cy - hh / 2.0; - b = cy + hh / 2.0; - } - } - // Dragging past the opposite edge flips. - let (mut fh, mut fv) = (o.flip_h, o.flip_v); - if r < l { - std::mem::swap(&mut l, &mut r); - fh = !fh; - } - if b < t { - std::mem::swap(&mut t, &mut b); - fv = !fv; - } - let (nw, nh) = (r - l, b - t); - // New centre in slide space: old centre + rotated offset of the local centre change. - let local_c = Point::new((l + r) / 2.0 * sx + if sx < 0.0 { o.w } else { 0.0 }, (t + b) / 2.0 * sy + if sy < 0.0 { o.h } else { 0.0 }); - let c = o.affine() * Point::new(if o.flip_h { o.w - local_c.x } else { local_c.x }, if o.flip_v { o.h - local_c.y } else { local_c.y }); - Xfrm { x: c.x - nw / 2.0, y: c.y - nh / 2.0, w: nw, h: nh, rot: o.rot, flip_h: fh, flip_v: fv } -} - -/// Whether `p` lies in a cell of table `id`, not in the grab band around it (`cell_at` would take -/// the nearest cell). -fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool { - st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)).is_some_and(|f| { - let q = f.local(p); - (0.0..=f.width()).contains(&q.x) && (0.0..=f.height()).contains(&q.y) && f.cell_at(q).is_some() - }) -} - -fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { - let x = xfrm_of(&st.doc, &st.selection, sh); - let inv = x.affine().inverse(); - let local = inv * p; - // Table borders resize rows and columns. - let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; - if near && let Some(f) = cmd::table::table_frame(st, sh) { - match f.border_at(f.local(p), tol) { - Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), - Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), - None => {} - } - } - match &sh.kind { - ShapeKind::Group { children, child } => { - // A group is hit when any child is hit; the group is what gets selected. - let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); - let m = deckcraft_geom::group_child_affine(&x, ch); - let q = m.inverse() * p; - for c in children.iter().rev() { - if let Some(cx) = c.xfrm { - let cl = cx.affine().inverse() * q; - if cl.x >= -tol && cl.y >= -tol && cl.x <= cx.w + tol && cl.y <= cx.h + tol { - return Some(Hit::Shape { id: sh.id, text: false }); - } - } - } - None - } - ShapeKind::Ink { strokes } => { - for s in strokes { - if s.points.iter().any(|(px, py, _)| (Point::new(*px, *py) - p).hypot() <= tol + s.width) { - return Some(Hit::Shape { id: sh.id, text: false }); - } - } - None - } - _ => { - if sh.is_line() { - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0); - if deckcraft_geom::near_path(&geo.outline(), local, tol.max(lw)) { - return Some(Hit::Shape { id: sh.id, text: false }); - } - return None; - } - let inside = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; - if !inside { - return None; - } - let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box); - // Empty interiors of unfilled, text-less shapes don't catch clicks. - let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some(); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - let in_geo = { - use deckcraft_geom::KurboShape; - geo.outline().winding(local) != 0 || deckcraft_geom::near_path(&geo.outline(), local, tol) - }; - if has_text - || sh.ph.is_some() - || matches!( - sh.kind, - ShapeKind::Picture { .. } - | ShapeKind::Table(_) - | ShapeKind::Chart(_) - | ShapeKind::Media(_) - | ShapeKind::Model3d { .. } - | ShapeKind::Opaque { .. } - ) - || (filled && in_geo) - || deckcraft_geom::near_path(&geo.outline(), local, tol) - { - return Some(Hit::Shape { id: sh.id, text: has_text || matches!(sh.kind, ShapeKind::Shape) }); - } - None - } - } -} - -/// Text position under slide point `p` in shape `sh`. -pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) { - cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) -} - -/// Row heights of table `t` as drawn: rows grow to fit their text. -pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec { - cmd::table_drawn_heights(&st.doc, &st.selection, t) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs new file mode 100644 index 000000000..4afff2656 --- /dev/null +++ b/crates/engine/src/tools/click.rs @@ -0,0 +1,219 @@ +use super::hit::{EditZone, edit_zone, edited_box, shape_in_front, text_member_at, text_pos}; +use super::*; + +impl Session { + pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result { + if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { + // The double-click's own click added a duplicate vertex. + while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) { + points.pop(); + } + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(false); + } + if !matches!(self.tool.kind, ToolKind::Select) { + // Double-click while drawing: lock drawing mode off. + self.tool.sticky = false; + return Ok(Value::Null); + } + // The double-click's second press started a click (caret on release): this replaces it. + if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { + self.tool.drag = None; + } + // In the text being edited: select the word double-clicked. + if self.place_caret(p, tol, false)?.is_some() { + return self.execute("text.selectWord", &json!({})); + } + match self.hit_test(p, tol) { + Some(Hit::Shape { id, .. }) => { + let st = self.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; + match &sh.kind { + ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + // Caret at the double-click (in a table, in the clicked cell), then select + // the word there. + if !self.edit_text_at(id, p)? { + return Ok(Value::Null); + } + self.execute("text.selectWord", &json!({})) + } + ShapeKind::Group { .. } => { + // A group is a single selection unit, but its text stays editable: + // double-click reaches the text-holding member under the cursor. + match text_member_at(st, &sh, p, tol).and_then(|mid| st.shape(mid).cloned().map(|m| (mid, m))) { + Some((mid, msh)) if !msh.is_line() => { + let pos = text_pos(st, &msh, p); + self.execute("text.edit", &json!({"id": mid, "at": [pos.0, pos.1]}))?; + self.execute("text.selectWord", &json!({})) + } + _ => Ok(Value::Null), + } + } + _ => { + self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); + Ok(Value::Null) + } + } + } + _ => Ok(Value::Null), + } + } + + /// Right-button press: the selection the context menu will act on, as in PowerPoint. In the + /// box being edited, the caret and text selection stay (the menu then acts on the text). On a + /// selected shape (or its handles) the selection stays, multiple selection included; on + /// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text, + /// starts a drag or adds an undo step. + pub(super) fn context_click(&mut self, p: Point, tol: f64) -> Result { + if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() { + return Ok(Value::Null); + } + let st = self.doc()?; + // The same zone in which a left press places the caret (`down`). + if let Some(t) = &st.selection.text + && !t.notes + && t.cell.is_none() + && let Some(sh) = st.shape(t.shape) + && xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p) + { + return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); + } + let hit = self.hit_test(p, tol); + let id = match &hit { + Some( + Hit::Shape { id, .. } + | Hit::Resize { id, .. } + | Hit::Rotate { id } + | Hit::Adjust { id, .. } + | Hit::LineEnd { id, .. } + | Hit::Handle { id, .. } + | Hit::Vertex { id, .. } + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. } + | Hit::Warp { id, .. }, + ) => Some(*id), + None => None, + }; + // Edit Points: the point or else the segment under the pointer picks the menu. + let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit { + Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None), + Some(Hit::Handle { .. }) => (None, None), + _ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)), + }); + if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { + self.select(|_, sel| { + sel.text = None; + sel.cells = None; + sel.shapes = id.into_iter().collect(); + })?; + } + if let Some((vertex, segment)) = picked { + // A control handle belongs to the picked point: its menu stays. + if !matches!(hit, Some(Hit::Handle { .. })) { + self.tool.edit_vertex = vertex; + } + self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg)); + self.tool.edit_segment_t = segment.map(|(_, _, t)| t); + } + let st = self.doc()?; + Ok(json!({ + "hit": serde_json::to_value(&hit).unwrap_or_default(), + "selected": st.selection.shapes, + "editing": st.selection.text.is_some(), + })) + } + + /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection + /// grows to it). In a table being edited, a click in another cell moves the caret into that + /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text + /// (or is on a handle or a table border). + pub(super) fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { + let st = self.doc()?; + let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; + let Some(sh) = st.shape(t.shape) else { return Ok(None) }; + // A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`). + // A table cell: anywhere over the table. + let inside = match t.cell { + None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), + Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), + }; + // A shape in front of the edited text takes the click, as in PowerPoint (#931). + let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0); + if !inside + || in_front + || matches!( + self.hit_test(p, tol), + Some( + Hit::Resize { .. } + | Hit::Rotate { .. } + | Hit::Adjust { .. } + | Hit::Warp { .. } + | Hit::Frame { .. } + | Hit::TableColumn { .. } + | Hit::TableRow { .. } + ) + ) + { + return Ok(None); + } + let old_cell = t.cell; + if old_cell.is_some() + && let Some(f) = cmd::table::table_frame(st, sh) + { + t.cell = f.cell_at(f.local(p)).or(old_cell); + } + let pos = cmd::text::text_pos_at(st, &t, p); + // A selection can't span cells: a click in another cell starts afresh. + let extend = extend && t.cell == old_cell; + let cell = t.cell; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.cell = cell; + tt.caret = pos; + if !extend { + tt.anchor = pos; + } + } + })?; + Ok(Some((t.shape, pos))) + } + + /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell + /// at `p`. Returns whether editing started. + pub(super) fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(false) }; + let params = match cmd::table::table_frame(st, sh) { + Some(f) => { + let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; + let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); + json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) + } + None => { + let pos = text_pos(st, sh, p); + json!({"id": id, "at": [pos.0, pos.1]}) + } + }; + self.execute("text.edit", ¶ms)?; + Ok(true) + } + + /// Press on a table border: dragging it resizes the column or row. Editing a cell of the + /// table goes on; otherwise the table becomes the selection. + pub(super) fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { + let st = self.doc()?; + let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; + let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); + let (start, frame) = (f.local(p), f.affine); + if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) { + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); + Ok(()) + } +} diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs new file mode 100644 index 000000000..fcdaf3da2 --- /dev/null +++ b/crates/engine/src/tools/drag.rs @@ -0,0 +1,319 @@ +use super::hit::{constrain_45, line_xfrm, resize_box}; +use super::*; + +impl Session { + pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; + match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } + Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { + let mut d = p - start; + if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { + return Ok(Value::Null); + } + if !moved { + self.begin(if duplicate { "Duplicate" } else { "Move" }); + if duplicate { + // Alt-drag: drag a copy, leave the original. + self.execute_dup_in_place(&ids)?; + } + } + let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() }; + if mods.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Smart guides on the moved group's bounds. + let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default(); + let moved_b = bounds + d; + let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd); + d += snap; + self.tool.guides = guides; + let origs2: Vec<(ShapeId, Xfrm)> = + if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?; + for (id, o) in &origs2 { + if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) { + sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); + } + } + // A connector dragged away from shapes that stay put comes unglued at those ends. + let moved: Vec = origs2.iter().map(|o| o.0).collect(); + for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) { + if let ShapeKind::Connector { start, end } = &mut sh.kind { + for c in [start, end] { + if c.is_some_and(|(to, _)| !moved.contains(&to)) { + *c = None; + } + } + } + } + Ok(()) + })?; + self.tool.drag = + Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); + } + Drag::Resize { id, handle, orig, start } => { + let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); + let nx = self.snap_resize(nx, &orig, handle, id, tol); + self.set_xfrm(id, nx)?; + } + Drag::LineEnd { id, end, orig } => { + let a = orig.affine(); + let (p0, p1) = (a * Point::new(0.0, 0.0), a * Point::new(orig.w, orig.h)); + let mut q = p; + let other = if end == 0 { p1 } else { p0 }; + if mods.shift { + q = constrain_45(other, q); + } + let glue = self.update_sites(q, tol, Some(id)); + if let Some((_, _, at)) = glue { + q = at; + } + let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) }; + self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?; + self.edit(|doc, sel| { + if let Some(list) = crate::shapes_mut(doc, sel) { + crate::connect::set_glue(list, id, end, glue.map(|g| (g.0, g.1))); + } + Ok(()) + })?; + } + Drag::Rotate { ids, center, start, origs } => { + let ang = (p.y - center.y).atan2(p.x - center.x); + let mut delta = (ang - start).to_degrees(); + for (id, o) in &origs { + let mut r = o.rot + delta; + if mods.shift { + r = (r / 15.0).round() * 15.0; + delta = r - o.rot; + } + let mut nx = *o; + nx.rot = ((r % 360.0) + 360.0) % 360.0; + if ids.len() > 1 { + // Rotate positions around the common centre too. + let c = o.center(); + let rc = Affine::translate(center.to_vec2()) * Affine::rotate(delta.to_radians()) * Affine::translate(-center.to_vec2()) * c; + nx.x = rc.x - o.w / 2.0; + nx.y = rc.y - o.h / 2.0; + } + self.set_xfrm(*id, nx)?; + } + } + Drag::Adjust { id, index, orig } => { + let st = self.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; + let local = orig.affine().inverse() * p; + let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h); + let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow")); + let vals: Vec<(usize, f64)> = if free2d { + let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5; + let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5; + vec![(0, dx.round()), (1, dy.round())] + } else { + vec![(index, h.value_at(local))] + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) + && let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom + { + let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); + for (i, v) in &vals { + while adj.len() <= *i { + let k = adj.len(); + adj.push(defaults.get(k).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(*i) { + *slot = *v; + } + } + } + Ok(()) + })?; + } + Drag::Vertex { id, path, index } => { + self.tool.drag = Some(Drag::Vertex { id, path, index }); + self.edit_vertex(id, path, index, p)?; + return Ok(Value::Null); + } + Drag::Handle { id, path, index, out } => { + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + let side = if out { "out" } else { "in" }; + // Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or + // corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt. + let held = if mods.shift { + Some("smooth") + } else if mods.cmd { + Some("straight") + } else if mods.alt { + Some("corner") + } else { + None + }; + if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) { + log::debug!("edit handle: {e}"); + } + return Ok(Value::Null); + } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } + Drag::Marquee { start, .. } => { + self.tool.marquee = Some(Rect::from_points(start, p)); + } + Drag::Create { start } => { + let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); + let x = if line { + let mut q = if mods.shift { constrain_45(start, p) } else { p }; + if let Some((_, _, at)) = self.update_sites(q, tol, None) { + q = at; + } + let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); + line_xfrm(s0, q, 0.0) + } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) + && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() + { + // The pencil inside a table draws a straight border, across or down. + let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; + let r = Rect::from_points(start, q); + Xfrm::new(r.x0, r.y0, r.width(), r.height()) + } else { + let mut r = Rect::from_points(start, p); + if mods.shift { + let s = r.width().max(r.height()); + let sx = if p.x < start.x { -s } else { s }; + let sy = if p.y < start.y { -s } else { s }; + r = Rect::from_points(start, start + Vec2::new(sx, sy)); + } + if mods.alt { + let d = p - start; + r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs()); + } + Xfrm::new(r.x0, r.y0, r.width(), r.height()) + }; + self.tool.preview = Some(x); + } + Drag::TextSelect { shape, press, .. } => { + // In a table, leaving the cell the drag started in selects whole cells, as in + // PowerPoint; coming back to that cell selects text in it again. + let st = self.doc()?; + if let Some(start) = press.as_ref().and_then(|t| t.cell) + && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) + && let Some(cell) = f.cell_at(f.local(p)) + { + if cell != start { + self.select(|_, sel| { + sel.text = None; + sel.shapes = vec![shape]; + sel.cells = Some((shape, start, cell)); + })?; + return Ok(Value::Null); + } + if st.selection.text.is_none() { + self.select(|_, sel| { + sel.cells = None; + sel.text = press; + })?; + } + } + let st = self.doc()?; + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); + self.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.caret = pos; + } + })?; + } + } + Drag::TableBorder { id, column, index, start, size, frame, moved } => { + let local = frame.inverse() * p; + let d = if column { local.x - start.x } else { local.y - start.y }; + if !moved && d.abs() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if column { "Column Width" } else { "Row Height" }); + } + // An inner column border moves between its two columns (the table keeps its + // width); the right edge widens the last column and the table. A row's height is + // a minimum: rows still grow to fit their text. + if column { + let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); + self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; + } else { + self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); + } + Drag::Freeform { mode, mut points, pressed } => { + // Dragging scribbles (except for curves, which only take clicked points). + if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { + points.push(p); + } + let mut v = points.clone(); + if mode == "curve" { + v.push(p); + } + self.tool.path_preview = v; + self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); + } + Drag::Ink { mut points } => { + points.push(self.ink_sample(p)); + self.tool.ink_preview = points.clone(); + self.tool.drag = Some(Drag::Ink { points }); + } + } + Ok(Value::Null) + } + + /// Moves a vertex during a drag (one undo step for the whole drag). + fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { + let r = cmd::points::move_point_raw(self, id, path, index, p); + if let Err(e) = r { + log::debug!("edit point: {e}"); + } + Ok(()) + } + + /// Moves a control handle during a drag. `held` is the point type set by the modifier held + /// during the drag; the point keeps that type for later drags, as after `shape.pointType`. + fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result { + let kind = match held { + Some(k) => Some(k.to_string()), + None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3), + }; + let r = + cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?; + if let Some(k) = held { + self.tool.vertex_kind = Some((id, path, index, k.to_string())); + } + Ok(r) + } +} diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs new file mode 100644 index 000000000..617b97913 --- /dev/null +++ b/crates/engine/src/tools/geometry.rs @@ -0,0 +1,325 @@ +use super::hit::table_rows; +use super::*; + +impl Session { + /// End the freeform being drawn: create the shape (if it has at least two points). + pub fn finish_freeform(&mut self, closed: bool) -> Result { + let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) }; + self.tool.path_preview.clear(); + self.tool.path_closing = false; + if !self.tool.sticky { + self.tool.kind = ToolKind::Select; + } + if points.len() < 2 { + return Ok(Value::Null); + } + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"})) + } + + /// The connection site within glue range of `p` (excluding shape `exclude`). + pub(super) fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> { + let st = self.active()?; + crate::connect::nearest_site(&st.doc, &st.selection, st.shapes(), p, (tol * 2.0).max(6.0), None) + } + + /// Show the sites of the shape near `p` and return the site an end at `p` would glue to. + pub(super) fn update_sites(&mut self, p: Point, tol: f64, exclude: Option) -> Option<(ShapeId, u32, Point)> { + let (sites, glue) = match self.active() { + Some(st) => { + let shapes = st.shapes(); + let near = crate::connect::site_shape_at(&st.doc, &st.selection, shapes, p, tol * 3.0, exclude); + let sites = near + .and_then(|id| shapes.iter().find(|s| s.id == id)) + .map(|s| crate::connect::sites(&st.doc, &st.selection, s)) + .unwrap_or_default(); + (sites, crate::connect::nearest_site(&st.doc, &st.selection, shapes, p, (tol * 2.0).max(6.0), exclude)) + } + None => (vec![], None), + }; + self.tool.sites = sites; + self.tool.glue = glue.map(|g| g.2); + glue + } + + pub(super) fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> { + // Rows scale from their drawn heights: a stored height is only a minimum (often 0). + let drawn = self.active().and_then(|st| match &st.shape(id)?.kind { + ShapeKind::Table(t) => Some(table_rows(st, t)), + _ => None, + }); + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + sh.xfrm = Some(x); + if let ShapeKind::Table(t) = &mut sh.kind { + // Tables resize their columns and rows proportionally. + let heights: Vec = match &drawn { + Some(d) if d.len() == t.rows.len() => d.clone(), + _ => t.rows.iter().map(|r| r.height).collect(), + }; + let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::()); + let kx = x.w / tw; + t.cols.iter_mut().for_each(|c| *c *= kx); + if th > 1e-6 { + let ky = x.h / th; + t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky); + } + } + } + Ok(()) + }) + } + + pub(super) fn set_xfrm_cmd(&mut self, id: ShapeId, x: Xfrm) -> Result { + self.execute("shape.setBounds", &json!({"id": id, "x": x.x, "y": x.y, "w": x.w, "h": x.h}))?; + if x.flip_h || x.flip_v { + self.edit(|doc, sel| { + if let Some(sh) = crate::shapes_mut(doc, sel).and_then(|l| deckcraft_model::find_shape_mut(l, id)) + && let Some(xx) = sh.xfrm.as_mut() + { + xx.flip_h = x.flip_h; + xx.flip_v = x.flip_v; + } + Ok(()) + })?; + } + Ok(Value::Null) + } + + pub(super) fn refit_after_resize(&mut self, id: ShapeId) -> Result<()> { + // A text box resized by hand keeps wrapping at its new width. + let st = self.doc()?; + if st.shape(id).is_some_and(|s| s.text_box) { + let _ = cmd::text::fit_text_box(self, id); + } + Ok(()) + } + + pub(super) fn execute_dup_in_place(&mut self, ids: &[ShapeId]) -> Result<()> { + self.edit(|doc, sel| { + let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); + let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); + let boxes: Vec = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + cmd::edit::reid("edit.duplicate", doc, &mut copies)?; + for (c, b) in copies.iter_mut().zip(boxes) { + c.xfrm = Some(b); + c.ph = None; + } + let new_ids: Vec = copies.iter().map(|c| c.id).collect(); + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("duplicate", "no slide"))?; + shapes.extend(copies); + sel.shapes = new_ids; + Ok(()) + }) + } + + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to + /// apply and the guides to draw. + pub(super) fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { + self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3]) + } + + /// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on` + /// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that + /// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped. + fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec) { + let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) }; + if !enabled || !self.prefs.smart_guides { + return (Vec2::ZERO, vec![]); + } + let size = st.doc.slide_size; + let mut xs: Vec<(f64, Rect)> = vec![ + (0.0, Rect::new(0.0, 0.0, 0.0, size.height)), + (size.width / 2.0, Rect::new(size.width / 2.0, 0.0, size.width / 2.0, size.height)), + (size.width, Rect::new(size.width, 0.0, size.width, size.height)), + ]; + let mut ys: Vec<(f64, Rect)> = vec![ + (0.0, Rect::new(0.0, 0.0, size.width, 0.0)), + (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), + (size.height, Rect::new(0.0, size.height, size.width, size.height)), + ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } + for sh in st.shapes() { + if exclude.contains(&sh.id) || sh.hidden { + continue; + } + let b = xfrm_of(&st.doc, &st.selection, sh).bounds(); + for x in [b.x0, b.center().x, b.x1] { + xs.push((x, b)); + } + for y in [b.y0, b.center().y, b.y1] { + ys.push((y, b)); + } + } + let mut best_x: Option<(f64, f64, Rect)> = None; + for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) { + for (x, b) in &xs { + let d = x - edge; + if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { + best_x = Some((d, *x, *b)); + } + } + } + let mut best_y: Option<(f64, f64, Rect)> = None; + for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) { + for (y, b) in &ys { + let d = y - edge; + if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { + best_y = Some((d, *y, *b)); + } + } + } + let mut off = Vec2::ZERO; + let mut guides = vec![]; + if let Some((d, x, b)) = best_x { + off.x = d; + let rr = r + Vec2::new(d, 0.0); + guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) }); + } else if self.prefs.snap_to_grid && xs_on.contains(&true) { + let at = if xs_on[0] { + r.x0 + } else if xs_on[2] { + r.x1 + } else { + r.center().x + }; + off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; + } + if let Some((d, y, b)) = best_y { + off.y = d; + let rr = r + Vec2::new(0.0, d); + guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) }); + } else if self.prefs.snap_to_grid && ys_on.contains(&true) { + let at = if ys_on[0] { + r.y0 + } else if ys_on[2] { + r.y1 + } else { + r.center().y + }; + off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; + } + (off, guides) + } + + /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. + pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { + if x.rot != 0.0 { + self.tool.guides.clear(); + return x; + } + let r = x.rect(); + // Only the dragged edges move, so only they look for a line to snap to. The handles sit in + // the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge + // just flipped, the dragged handle is on the other side. + let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4)); + let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); + let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) }; + let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) }; + let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); + let mut nx = x; + if moves_left { + nx.x += off.x; + nx.w -= off.x; + } else if moves_right { + nx.w += off.x; + } + if moves_top { + nx.y += off.y; + nx.h -= off.y; + } else if moves_bottom { + nx.h += off.y; + } + nx.w = nx.w.max(0.0); + nx.h = nx.h.max(0.0); + self.tool.guides = guides; + nx + } + + /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. + pub(super) fn ink_sample(&self, p: Point) -> (f64, f64, f32) { + let pressure = match self.tool.pressure { + Some(v) if v.is_finite() => v.clamp(0.0, 1.0), + _ => 1.0, + }; + (p.x, p.y, pressure) + } + + pub(super) fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { + let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; + let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; + // Strokes join the slide's ink layer (one ink shape per slide). + let existing = self.doc()?.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id); + self.edit(|doc, sel| { + let id = match existing { + Some(id) => id, + None => { + let id = ShapeId(doc.alloc_id().ok_or_else(|| cmd::bad("ink.draw", cmd::NO_ID_SPACE))?); + let size = doc.slide_size; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + list.push(Shape { + id, + name: "Ink".into(), + xfrm: Some(Xfrm::new(0.0, 0.0, size.width, size.height)), + kind: ShapeKind::Ink { strokes: vec![] }, + ..Default::default() + }); + id + } + }; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) + && let ShapeKind::Ink { strokes } = &mut sh.kind + { + strokes.push(stroke); + } + Ok(json!({"ink": id})) + }) + } + + pub(super) fn erase_at(&mut self, p: Point, tol: f64) -> Result { + let st = self.doc()?; + let Some(ink) = st.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id) else { return Ok(Value::Null) }; + let before = st.doc.clone(); + let sel_before = st.selection.clone(); + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, ink) + && let ShapeKind::Ink { strokes } = &mut sh.kind + { + strokes.retain(|s| !s.points.iter().any(|(x, y, _)| (Point::new(*x, *y) - p).hypot() <= tol * 2.0 + s.width)); + } + Ok(()) + })?; + let vertex_kind = self.tool.vertex_kind.clone(); + if let Some(st) = self.active_mut() { + crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind }); + } + Ok(Value::Null) + } +} diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs new file mode 100644 index 000000000..e61051740 --- /dev/null +++ b/crates/engine/src/tools/hit.rs @@ -0,0 +1,306 @@ +use super::*; + +pub(super) fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { + st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) +} + +/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. +/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is +/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing. +pub(super) fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { + let ShapeKind::Group { children, child } = &sh.kind else { return None }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol) +} + +/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`. +/// `to_abs` maps this level's child space to slide space. +fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option { + let q = to_abs.inverse() * p; + for c in children.iter().rev() { + let Some(cx) = c.xfrm else { continue }; + let local = cx.affine().inverse() * q; + if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol { + continue; + } + if let ShapeKind::Group { children: gc, child: gch } = &c.kind { + let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h); + let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch); + if let Some(id) = member_at(gc, nested, p, tol) { + return Some(id); + } + } else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() { + return Some(c.id); + } + } + None +} + +/// The box whose text is being edited on the slide (not speaker notes, not a table cell). +pub(super) struct EditedBox { + pub(super) id: ShapeId, + pub(super) xfrm: Xfrm, + /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. + pub(super) movable: bool, +} + +/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its +/// list, topmost first, and inside a group that holds it, the members above it. +pub(super) fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool { + if depth > 64 { + return false; + } + for s in shapes.iter().rev() { + if s.id == target { + return false; + } + if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() { + // The group holding the text: look among its members. + return shape_in_front(st, s.children(), target, p, tol, depth + 1); + } + if !s.hidden && hit_shape(st, s, p, tol).is_some() { + return true; + } + } + false +} + +pub(super) fn edited_box(st: &crate::DocState) -> Option { + let t = st.selection.text.as_ref()?; + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked }) +} + +/// Where a point falls against the box of the text being edited. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum EditZone { + /// Well inside: the caret goes there. + Inside, + /// On the frame: a band `tol` wide on either side of the box edge. + Frame, + Outside, +} + +/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside +/// small boxes the band narrows to a quarter of the box so their text stays reachable. +pub(super) fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { + let l = x.affine().inverse() * p; + let (w, h) = (x.w.max(0.0), x.h.max(0.0)); + let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; + if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { + return EditZone::Outside; + } + let inner = tol.min(w / 4.0).min(h / 4.0); + if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } +} + +pub(super) fn contains_rect(outer: Rect, inner: Rect) -> bool { + inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 +} + +pub(super) fn constrain_45(a: Point, b: Point) -> Point { + let d = b - a; + let ang = d.y.atan2(d.x); + let snapped = (ang / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; + let len = d.hypot(); + a + Vec2::new(snapped.cos(), snapped.sin()) * len +} + +/// A line-like shape's box for endpoints `a` → `b` (flips encode direction). +pub fn line_xfrm(a: Point, b: Point, _rot: f64) -> Xfrm { + let r = Rect::from_points(a, b); + Xfrm { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rot: 0.0, flip_h: b.x < a.x, flip_v: b.y < a.y } +} + +/// New box when dragging resize handle `h` by `d` (slide space), keeping the shape's rotation. +pub fn resize_box(o: &Xfrm, h: u8, d: Vec2, keep_aspect: bool, from_center: bool) -> Xfrm { + // Work in the shape's rotated frame. + let rot = Affine::rotate(-o.rot.to_radians()); + let ld = rot * Point::new(d.x, d.y); + let (mut l, mut t, mut r, mut b) = (0.0, 0.0, o.w, o.h); + let (sx, sy) = (if o.flip_h { -1.0 } else { 1.0 }, if o.flip_v { -1.0 } else { 1.0 }); + let (dx, dy) = (ld.x * sx, ld.y * sy); + match h { + 0 => { + l += dx; + t += dy; + } + 1 => t += dy, + 2 => { + r += dx; + t += dy; + } + 3 => r += dx, + 4 => { + r += dx; + b += dy; + } + 5 => b += dy, + 6 => { + l += dx; + b += dy; + } + _ => l += dx, + } + if from_center { + // Symmetric growth about the centre. + let gw = match h { + 0 | 6 | 7 => -dx, + 2..=4 => dx, + _ => 0.0, + }; + let gh = match h { + 0..=2 => -dy, + 4..=6 => dy, + _ => 0.0, + }; + l = -gw; + r = o.w + gw; + t = -gh; + b = o.h + gh; + } + let mut w = r - l; + let mut hh = b - t; + if keep_aspect && o.w > 1e-9 && o.h > 1e-9 && !matches!(h, 1 | 3 | 5 | 7) { + let ar = o.w / o.h; + if (w / ar).abs() > hh.abs() { + hh = w.abs() / ar * hh.signum().max(if hh == 0.0 { 1.0 } else { hh.signum() }); + } else { + w = hh.abs() * ar * w.signum().max(if w == 0.0 { 1.0 } else { w.signum() }); + } + if matches!(h, 0 | 6) { + l = r - w; + } else { + r = l + w; + } + if matches!(h, 0 | 2) { + t = b - hh; + } else { + b = t + hh; + } + if from_center { + let (cx, cy) = (o.w / 2.0, o.h / 2.0); + l = cx - w / 2.0; + r = cx + w / 2.0; + t = cy - hh / 2.0; + b = cy + hh / 2.0; + } + } + // Dragging past the opposite edge flips. + let (mut fh, mut fv) = (o.flip_h, o.flip_v); + if r < l { + std::mem::swap(&mut l, &mut r); + fh = !fh; + } + if b < t { + std::mem::swap(&mut t, &mut b); + fv = !fv; + } + let (nw, nh) = (r - l, b - t); + // New centre in slide space: old centre + rotated offset of the local centre change. + let local_c = Point::new((l + r) / 2.0 * sx + if sx < 0.0 { o.w } else { 0.0 }, (t + b) / 2.0 * sy + if sy < 0.0 { o.h } else { 0.0 }); + let c = o.affine() * Point::new(if o.flip_h { o.w - local_c.x } else { local_c.x }, if o.flip_v { o.h - local_c.y } else { local_c.y }); + Xfrm { x: c.x - nw / 2.0, y: c.y - nh / 2.0, w: nw, h: nh, rot: o.rot, flip_h: fh, flip_v: fv } +} + +/// Whether `p` lies in a cell of table `id`, not in the grab band around it (`cell_at` would take +/// the nearest cell). +pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool { + st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)).is_some_and(|f| { + let q = f.local(p); + (0.0..=f.width()).contains(&q.x) && (0.0..=f.height()).contains(&q.y) && f.cell_at(q).is_some() + }) +} + +pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { + let x = xfrm_of(&st.doc, &st.selection, sh); + let inv = x.affine().inverse(); + let local = inv * p; + // Table borders resize rows and columns. + let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if near && let Some(f) = cmd::table::table_frame(st, sh) { + match f.border_at(f.local(p), tol) { + Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), + Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), + None => {} + } + } + match &sh.kind { + ShapeKind::Group { children, child } => { + // A group is hit when any child is hit; the group is what gets selected. + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let m = deckcraft_geom::group_child_affine(&x, ch); + let q = m.inverse() * p; + for c in children.iter().rev() { + if let Some(cx) = c.xfrm { + let cl = cx.affine().inverse() * q; + if cl.x >= -tol && cl.y >= -tol && cl.x <= cx.w + tol && cl.y <= cx.h + tol { + return Some(Hit::Shape { id: sh.id, text: false }); + } + } + } + None + } + ShapeKind::Ink { strokes } => { + for s in strokes { + if s.points.iter().any(|(px, py, _)| (Point::new(*px, *py) - p).hypot() <= tol + s.width) { + return Some(Hit::Shape { id: sh.id, text: false }); + } + } + None + } + _ => { + if sh.is_line() { + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0); + if deckcraft_geom::near_path(&geo.outline(), local, tol.max(lw)) { + return Some(Hit::Shape { id: sh.id, text: false }); + } + return None; + } + let inside = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if !inside { + return None; + } + let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box); + // Empty interiors of unfilled, text-less shapes don't catch clicks. + let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some(); + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let in_geo = { + use deckcraft_geom::KurboShape; + geo.outline().winding(local) != 0 || deckcraft_geom::near_path(&geo.outline(), local, tol) + }; + if has_text + || sh.ph.is_some() + || matches!( + sh.kind, + ShapeKind::Picture { .. } + | ShapeKind::Table(_) + | ShapeKind::Chart(_) + | ShapeKind::Media(_) + | ShapeKind::Model3d { .. } + | ShapeKind::Opaque { .. } + ) + || (filled && in_geo) + || deckcraft_geom::near_path(&geo.outline(), local, tol) + { + return Some(Hit::Shape { id: sh.id, text: has_text || matches!(sh.kind, ShapeKind::Shape) }); + } + None + } + } +} + +/// Text position under slide point `p` in shape `sh`. +pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) { + cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) +} + +/// Row heights of table `t` as drawn: rows grow to fit their text. +pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec { + cmd::table_drawn_heights(&st.doc, &st.selection, t) +} diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs new file mode 100644 index 000000000..adba0a5f6 --- /dev/null +++ b/crates/engine/src/tools/pointer.rs @@ -0,0 +1,580 @@ +use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, shape_in_front}; +use super::*; + +impl Session { + pub fn set_tool(&mut self, kind: ToolKind) { + self.tool.kind = kind; + self.tool.drag = None; + self.tool.preview = None; + self.tool.marquee = None; + } + + /// What's under `p` on the current slide. + pub fn hit_test(&self, p: Point, tol: f64) -> Option { + let st = self.active()?; + // In Edit Points, the edited shape's vertices come first. + if let Some(id) = self.tool.editing_points(st) + && let Some(sh) = st.shape(id) + { + if let Some((path, index)) = self.tool.edit_vertex + && let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for (h, out) in [(hout, true), (hin, false)] { + if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) { + return Some(Hit::Handle { id, path, index, out }); + } + } + } + if let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) { + return Some(Hit::Vertex { id, path, index }); + } + } + // Handles of selected shapes first. + if st.selection.text.is_none() || st.selection.shapes.len() == 1 { + for id in st.selection.shapes.iter().rev() { + let Some(sh) = st.shape(*id) else { continue }; + // A locked object shows no live handles: it can't be sized, rotated or reshaped. + if sh.locked { + continue; + } + let x = xfrm_of(&st.doc, &st.selection, sh); + if sh.is_line() { + let a = x.affine(); + for (end, q) in [(0u8, a * Point::new(0.0, 0.0)), (1u8, a * Point::new(x.w, x.h))] { + if (q - p).hypot() <= tol * 1.6 { + return Some(Hit::LineEnd { id: *id, end }); + } + } + continue; + } + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + for hd in &geo.handles { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Adjust { id: *id, index: hd.index }); + } + } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } + let (pts, rot) = box_handles(&x); + if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { + return Some(Hit::Rotate { id: *id }); + } + for (i, q) in pts.iter().enumerate() { + if (*q - p).hypot() <= tol * 1.6 { + return Some(Hit::Resize { id: *id, handle: i as u8 }); + } + } + } + } + // The frame of the text being edited: grabbing it moves the box, unless a shape in front + // of the text covers that spot (#931). + if let Some(e) = edited_box(st) + && e.movable + && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) + { + return Some(Hit::Frame { id: e.id }); + } + // Shapes, topmost first. + // Locked objects are still picked (to format them or type in them); they just don't move. + for sh in st.shapes().iter().rev() { + if sh.hidden { + continue; + } + if let Some(h) = hit_shape(st, sh, p, tol) { + return Some(h); + } + } + None + } + + /// Feed a pointer event to the active tool. Returns what happened (for agents). + pub fn pointer(&mut self, ev: PointerEvent) -> Result { + let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; + let p = Point::new(ev.x, ev.y); + self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + if !(p.x.is_finite() && p.y.is_finite()) { + return Ok(Value::Null); + } + // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. + if !matches!(ev.kind, PointerKind::Move) { + self.settle_composition(); + } + match ev.kind { + PointerKind::Move => { + if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { + let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0); + let mut v = points.clone(); + v.push(p); + self.tool.path_preview = v; + self.tool.path_smooth = mode == "curve"; + self.tool.path_closing = closing; + return Ok(Value::Null); + } + self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; + let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); + if line_tool { + self.update_sites(p, tol, None); + } else { + self.tool.sites.clear(); + self.tool.glue = None; + } + Ok(Value::Null) + } + PointerKind::Down => self.down(p, ev.mods, tol), + PointerKind::Drag => self.drag(p, ev.mods, tol), + PointerKind::Up => self.up(p, ev.mods), + PointerKind::DoubleClick => { + let before = self.text_selection(); + let r = self.double_click(p, tol)?; + self.paint_if_selection_changed(before, r) + } + PointerKind::ContextClick => self.context_click(p, tol), + PointerKind::TripleClick => { + if self.active().is_some_and(|d| d.selection.text.is_some()) { + let before = self.text_selection(); + let r = self.execute("text.selectParagraph", &json!({}))?; + self.paint_if_selection_changed(before, r) + } else { + Ok(Value::Null) + } + } + } + } + + /// Format Painter applies once a text range is selected (drag, word or paragraph click). + fn paint_text_selection(&mut self) -> Result> { + if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) { + return self.execute("format.painterApply", &json!({})).map(Some); + } + Ok(None) + } + + /// Where the text selection is: its shape, cell or notes, anchor and caret. + fn text_selection(&self) -> Option { + self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret)) + } + + /// A double or triple click paints only the range it selected, not an older one it left alone. + fn paint_if_selection_changed(&mut self, before: Option, r: Value) -> Result { + if self.text_selection() == before { + return Ok(r); + } + self.paint_text_selection().map(|a| a.unwrap_or(r)) + } + + pub(crate) fn begin(&mut self, label: &str) { + let vertex_kind = self.tool.vertex_kind.clone(); + if let Some(st) = self.active_mut() + && st.interaction.is_none() + { + st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind }); + } + } + + fn commit(&mut self) { + if let Some(st) = self.active_mut() + && let Some(i) = st.interaction.take() + && !Arc::ptr_eq(&i.doc, &st.doc) + { + crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection, vertex_kind: i.vertex_kind }); + } + } + + /// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the + /// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled. + pub(crate) fn cancel_point_drag(&mut self) -> bool { + if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) { + return false; + } + self.tool.drag = None; + if let Some(st) = self.active_mut() + && st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points") + && let Some(i) = st.interaction.take() + { + // A modifier held during the drag may have set a point type; the drag is undone, so is that. + let kind = i.vertex_kind; + if !Arc::ptr_eq(&i.doc, &st.doc) { + st.doc = i.doc; + st.selection = i.selection; + st.revision += 1; + } + self.tool.vertex_kind = kind; + } + true + } + + /// End the interaction only when it is the one `label` started. A twist must not commit a drag. + pub(crate) fn commit_named(&mut self, label: &str) { + let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); + if ours { + self.commit(); + } + } + + fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { + // Next click of a freeform: close on the start point, else add a vertex. + if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) { + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(true); + } + points.push(p); + self.tool.path_preview = points.clone(); + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); + return Ok(Value::Null); + } + match self.tool.kind.clone() { + ToolKind::Shape { preset } if is_freeform_tool(&preset) => { + self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true }); + self.tool.path_preview = vec![p]; + self.tool.path_smooth = preset == "curve"; + self.tool.path_closing = false; + return Ok(Value::Null); + } + ToolKind::Shape { preset } if preset == TABLE_ERASER => { + // Click a border to merge the cells on either side; elsewhere does nothing. + return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(Value::Null)); + } + ToolKind::Shape { .. } | ToolKind::TextBox => { + self.tool.drag = Some(Drag::Create { start: p }); + self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); + return Ok(Value::Null); + } + ToolKind::Ink { mode, .. } => { + if mode == "eraser" { + return self.erase_at(p, tol); + } + let sample = self.ink_sample(p); + self.tool.drag = Some(Drag::Ink { points: vec![sample] }); + self.tool.ink_preview = vec![sample]; + return Ok(Value::Null); + } + ToolKind::Select => {} + } + // Clicking inside the text being edited moves the caret / starts a text selection. + let before = self.text_selection(); + if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { + let press = self.doc()?.selection.text.clone(); + self.tool.drag = Some(Drag::TextSelect { shape, before, press }); + return Ok(json!({"caret": [pos.0, pos.1]})); + } + let hit = self.hit_test(p, tol); + // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; + // a click anywhere else leaves Edit Points. + if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { + if let Some(Hit::Vertex { id, path, index }) = hit { + if mods.cmd { + return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); + } + self.tool.edit_vertex = Some((path, index)); + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Vertex { id, path, index }); + return Ok(json!({"vertex": [path, index]})); + } + if let Some(Hit::Handle { id, path, index, out }) = hit { + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + return Ok(json!({"handle": [path, index]})); + } + let st = self.doc()?; + if let Some(sh) = st.shape(eid) + && let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6) + { + if mods.cmd { + return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t})); + } + return Ok(Value::Null); + } + self.tool.edit_points = None; + self.tool.edit_vertex = None; + } + let st = self.doc()?; + match hit { + Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, + Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, + Some(Hit::Resize { id, handle }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Size"); + self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p }); + } + Some(Hit::LineEnd { id, end }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Size"); + self.tool.drag = Some(Drag::LineEnd { id, end, orig }); + } + Some(Hit::Rotate { id }) => { + let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; + let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + let center = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s).center()).unwrap_or(p); + let start = (p.y - center.y).atan2(p.x - center.x); + self.begin("Rotate"); + self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); + } + // Only reached outside Edit Points (handled above). + Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} + Some(Hit::Adjust { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Adjust Shape"); + self.tool.drag = Some(Drag::Adjust { id, index, orig }); + } + Some(Hit::Frame { id }) => { + // Like PowerPoint: the box leaves text editing and is selected whole (a click + // without a drag stops there), and dragging moves it as one undo step. + let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); + } + Some(Hit::Warp { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let found = st.shape(id).and_then(|sh| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } + Some(Hit::Shape { id, .. }) + if !(mods.shift || mods.cmd) + && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) + && in_table_cell(st, id, p) => + { + // A press in a table whose cells are selected starts again in the cell under the + // pointer: a click puts the caret there and a drag selects another range. A press + // on the table outside its cells selects and moves the table like any shape. + let before = self.text_selection(); + if self.edit_text_at(id, p)? { + self.select(|_, sel| sel.cells = None)?; + let press = self.doc()?.selection.text.clone(); + self.tool.drag = Some(Drag::TextSelect { shape: id, before, press }); + } + } + Some(Hit::Shape { id, text }) => { + let selected = st.selection.shapes.contains(&id); + let toggle = mods.shift || mods.cmd; + let ids: Vec = if toggle { + let mut v = st.selection.shapes.clone(); + if selected { + v.retain(|x| *x != id); + } else { + v.push(id); + } + v + } else if selected { + st.selection.shapes.clone() + } else { + vec![id] + }; + // Locked objects stay put when the selection is dragged. + let origs: Vec<(ShapeId, Xfrm)> = + ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + // A click (no drag) on text puts the caret there; unselected text boxes and + // placeholders take the caret straight away like PowerPoint. A click in a cell of a + // selected table puts the caret there; a click on its grab band keeps the table selected. + let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); + // A click on the frame band of a text box selects the box whole, as PowerPoint + // does, so it can be moved, duplicated or deleted; only the inside takes the caret. + let on_frame = text + && is_text_first(st, id) + && st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame); + let enter_text = + (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected && in_table_cell(st, id, p)))) + .then_some((id, p)); + let ids2 = ids.clone(); + self.select(|_, sel| { + sel.shapes = ids2; + sel.text = None; + sel.cells = None; + })?; + let duplicate = mods.alt && origs.len() == ids.len(); + self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text }); + // Format Painter applies on click. + if self.painter.is_some() && !toggle { + let _ = self.execute("format.painterApply", &json!({"ids": [id]})); + } + // Animation Painter applies on click. + if self.anim_painter.is_some() && !toggle { + let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); + } + } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } + None => { + let add = mods.shift || mods.cmd; + self.select(|_, sel| { + sel.text = None; + sel.cells = None; + if !add { + sel.shapes.clear(); + } + })?; + self.tool.drag = Some(Drag::Marquee { start: p, add }); + self.tool.marquee = Some(Rect::from_points(p, p)); + } + } + Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})) + } + + fn up(&mut self, p: Point, mods: Mods) -> Result { + let drag = self.tool.drag.take(); + self.tool.guides.clear(); + self.tool.sites.clear(); + self.tool.glue = None; + let mut out = Value::Null; + match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } + Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { + // Plain click on text: caret at the click (in a table, in the clicked cell). + if self.edit_text_at(id, at)? { + out = json!({"editing": id}); + } + } + Some(Drag::Marquee { start, add }) => { + let r = Rect::from_points(start, p); + self.tool.marquee = None; + if r.width() > 1.0 || r.height() > 1.0 { + let st = self.doc()?; + let ids: Vec = st + .shapes() + .iter() + .filter(|s| !s.hidden && !s.locked) + .filter(|s| contains_rect(r, xfrm_of(&st.doc, &st.selection, s).bounds())) + .map(|s| s.id) + .collect(); + self.select(|_, sel| { + if add { + for i in &ids { + if !sel.shapes.contains(i) { + sel.shapes.push(*i); + } + } + } else { + sel.shapes = ids.clone(); + } + })?; + out = json!({"selected": ids}); + } + } + Some(Drag::Create { start }) => { + let kind = self.tool.kind.clone(); + let preview = self.tool.preview.take(); + let dragged = (p - start).hypot() > 2.0; + let res = match &kind { + ToolKind::Shape { preset } if preset == DRAW_TABLE => { + // Inside a table: split cells along the line. Elsewhere: a one-cell table. + if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() { + self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null) + } else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) { + self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))? + } else { + Value::Null + } + } + ToolKind::Shape { preset } => { + let line = deckcraft_geom::preset::is_line_like(preset); + let rect = match preview.filter(|_| dragged) { + Some(x) => x, + None if line => Xfrm::new(start.x, start.y, 72.0, 0.0), + None => Xfrm::new(start.x, start.y, 72.0, 72.0), + }; + let r = self.execute("shape.insert", &json!({"preset": preset, "rect": [rect.x, rect.y, rect.w, rect.h], "flip": format!("{}{}", if rect.flip_h { "h" } else { "" }, if rect.flip_v { "v" } else { "" })}))?; + if let (Some(id), Some(x)) = (r.get("id").and_then(Value::as_u64), preview.filter(|_| dragged && line)) { + let id = ShapeId(id as u32); + let _ = self.set_xfrm_cmd(id, x); + // Glue the ends that landed on connection sites. + let (p0, p1) = crate::connect::endpoints(&x); + let (g0, g1) = (self.glue_at(p0, 4.0), self.glue_at(p1, 4.0)); + if g0.is_some() || g1.is_some() { + let _ = self.edit(|doc, sel| { + if let Some(list) = crate::shapes_mut(doc, sel) { + crate::connect::set_glue(list, id, 0, g0.map(|g| (g.0, g.1))); + crate::connect::set_glue(list, id, 1, g1.map(|g| (g.0, g.1))); + } + Ok(()) + }); + } + } + r + } + ToolKind::TextBox => { + let w = preview.filter(|_| dragged).map(|x| x.w).unwrap_or(0.0); + let at = preview.filter(|_| dragged).map(|x| Point::new(x.x, x.y)).unwrap_or(start); + self.execute("insert.textBox", &json!({"rect": [at.x, at.y, w, 30.0]}))? + } + _ => Value::Null, + }; + // The table pencil stays on until you pick another tool, as in PowerPoint. + if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) { + self.tool.kind = ToolKind::Select; + } + out = res; + } + Some(Drag::Freeform { mode, points, .. }) => { + if mode == "scribble" { + // A scribble ending back at its start closes into a filled shape. + let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0); + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(closed); + } + // Polygon / curve: keep going until double-click, Enter or a click on the start. + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return Ok(Value::Null); + } + Some(Drag::Ink { points }) => { + self.tool.ink_preview.clear(); + if points.len() > 1 { + self.begin("Ink"); + out = self.add_ink(points)?; + } + } + Some(Drag::Resize { id, .. }) => { + self.commit(); + let _ = cmd::text::fit_text_box(self, id); + self.refit_after_resize(id)?; + } + Some(Drag::TextSelect { before, .. }) => { + out = self.paint_if_selection_changed(before, out)?; + self.commit(); + } + Some(_) => self.commit(), + None => {} + } + // Any open interaction (move etc.) ends here. + self.commit(); + let _ = mods; + Ok(out) + } + + pub(super) fn is_picture(&self, id: ShapeId) -> bool { + self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) + } +} From 5b02b1744c537aa019ed16f7c462e1bd9e49143c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:17:01 +0000 Subject: [PATCH 54/90] refactor(render): split lib.rs into {raster,opts,slide,renderer,geometry,stroke,text_draw}.rs --- crates/render/src/geometry.rs | 201 +++ crates/render/src/lib.rs | 2466 +------------------------------- crates/render/src/opts.rs | 132 ++ crates/render/src/raster.rs | 113 ++ crates/render/src/renderer.rs | 1024 +++++++++++++ crates/render/src/slide.rs | 230 +++ crates/render/src/stroke.rs | 441 ++++++ crates/render/src/text_draw.rs | 318 ++++ 8 files changed, 2486 insertions(+), 2439 deletions(-) create mode 100644 crates/render/src/geometry.rs create mode 100644 crates/render/src/opts.rs create mode 100644 crates/render/src/raster.rs create mode 100644 crates/render/src/renderer.rs create mode 100644 crates/render/src/slide.rs create mode 100644 crates/render/src/stroke.rs create mode 100644 crates/render/src/text_draw.rs diff --git a/crates/render/src/geometry.rs b/crates/render/src/geometry.rs new file mode 100644 index 000000000..12b227961 --- /dev/null +++ b/crates/render/src/geometry.rs @@ -0,0 +1,201 @@ +use super::*; + +pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color { + let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8; + peniko::Color::from_rgba8(c.r, c.g, c.b, a) +} + +pub(crate) fn shade(c: Rgba, m: FillMode) -> Rgba { + match m { + FillMode::Lighten => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.4), + FillMode::LightenLess => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.2), + FillMode::Darken => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.4), + FillMode::DarkenLess => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.2), + _ => c, + } +} + +/// Geometry of a shape at its size. +pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry { + match &shape.geom { + Geom::Preset { name, adj } => preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).unwrap_or(Geometry { + paths: vec![], + text_rect: Rect::new(0.0, 0.0, w, h), + handles: vec![], + sites: vec![], + }), + Geom::Custom { paths } => { + let mut out = vec![]; + for p in paths { + let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) }); + let mut bp = parse_path(&p.d); + bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph)); + // PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path + // inside another cuts a hole whichever way it winds, as in icons and outlined text. + out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true }); + } + Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] } + } + } +} + +/// Parse our custom path syntax (`M x y L x y C … Q … Z`). +pub fn parse_path(d: &str) -> BezPath { + let mut bp = BezPath::new(); + let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); + let mut started = false; + while i < toks.len() { + let t = toks.get(i).copied().unwrap_or(""); + match t { + "M" => { + if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) { + bp.move_to((x, y)); + started = true; + } + i += 3; + } + "L" => { + if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) { + if !started { + bp.move_to((x, y)); + started = true; + } else { + bp.line_to((x, y)); + } + } + i += 3; + } + "C" => { + if let (Some(a), Some(b), Some(c), Some(d2), Some(e), Some(f)) = + (num(i + 1), num(i + 2), num(i + 3), num(i + 4), num(i + 5), num(i + 6)) + && started + { + bp.curve_to((a, b), (c, d2), (e, f)); + } + i += 7; + } + "Q" => { + if let (Some(a), Some(b), Some(c), Some(d2)) = (num(i + 1), num(i + 2), num(i + 3), num(i + 4)) + && started + { + bp.quad_to((a, b), (c, d2)); + } + i += 5; + } + "Z" | "z" => { + if started { + bp.close_path(); + } + i += 1; + } + _ => i += 1, + } + } + bp +} + +/// The transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine +/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts. +pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option { + if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { + return None; + } + let (e, _) = resolve::effects(rctx, s); + e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h)) +} + +/// The rectangle a picture's image occupies given its crop (fractions; negative = padding). +pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(-10.0, 0.99) } else { 0.0 }); + let vis_w = (1.0 - l - r).max(1e-6); + let vis_h = (1.0 - t - b).max(1e-6); + let fw = bounds.width() / vis_w; + let fh = bounds.height() / vis_h; + Rect::new(bounds.x0 - l * fw, bounds.y0 - t * fh, bounds.x0 - l * fw + fw, bounds.y0 - t * fh + fh) +} + +/// Whether a shape's border is drawn outside its outline: a picture's is, unless the picture is +/// drawn in 3-D (a bevel seen through a 3-D scene), where PowerPoint centres it on the edge as on +/// any shape (observed: an 18 pt border covers 9 pt of the picture then). +pub(crate) fn border_outside(is_pic: bool, effects: Option<&Effects>) -> bool { + is_pic && !effects.is_some_and(|e| e.bevel.is_some() && e.raw3d.as_deref().is_some_and(|r| r.contains("scene3d"))) +} + +/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside +/// the outline is the border, with dashes kept their length. `None` for compound lines, whose +/// bands would not survive the doubling (they stay centred on the outline). +pub(crate) fn outside_line(line: &Line, lw: f64) -> Option { + if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) { + return None; + } + let dash = line.dash.as_ref().map(|d| { + let p = d.pattern(); + deckcraft_model::style::Dash::Custom { + pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(), + } + }); + Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() }) +} + +/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity, +/// and the space it is stroked in (see `Renderer::line_space`). +pub(crate) struct Caster<'a> { + pub(crate) line: std::borrow::Cow<'a, Line>, + pub(crate) ph: Option, + pub(crate) alpha: f64, + pub(crate) base: Affine, + pub(crate) rel: Affine, +} + +/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a +/// gradient's mean stop alpha, otherwise opaque. +pub(crate) fn fill_opacity(rctx: &Ctx, fill: &Option, ph: Option) -> Option { + match fill { + None | Some(Fill::None) => None, + Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0), + Some(Fill::Gradient(g)) if !g.stops.is_empty() => { + Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::() / g.stops.len() as f64) + } + Some(_) => Some(1.0), + } +} + +/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point +/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the +/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow +/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its +/// middle. +pub(crate) fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine { + let (ax, ay) = match sh.align.as_str() { + "tl" => (0.0, 0.0), + "t" => (0.5, 0.0), + "tr" => (1.0, 0.0), + "l" => (0.0, 0.5), + "r" => (1.0, 0.5), + "bl" => (0.0, 1.0), + "b" => (0.5, 1.0), + "br" => (1.0, 1.0), + _ => (0.5, 0.5), + }; + let a = Vec2::new(w * ax, h * ay); + let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan()); + let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]); + if !k.is_finite() { + return Affine::IDENTITY; + } + Affine::translate(a) * k * Affine::translate(-a) +} + +pub(crate) fn pre_translate(m: Affine, d: Vec2) -> Affine { + Affine::translate(d) * m +} + +pub(crate) fn scale_of(m: Affine) -> f64 { + m.determinant().abs().sqrt() +} + +pub(crate) fn blur(sigma: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1e7c3dca8..5fd011411 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,2445 +60,33 @@ pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; pub use transition::{SlideParts, composite_transition, render_transition}; -/// Largest pixmap side we render (vello_cpu uses u16 sizes). -pub const MAX_SIDE: u32 = 16_000; - -/// A rendered image: premultiplied RGBA8. -#[derive(Clone, Debug, Default)] -pub struct Image { - pub width: u32, - pub height: u32, - pub pixels: Vec, -} - -impl Image { - /// Straight (unpremultiplied) RGBA. - pub fn to_straight(&self) -> Vec { - let mut out = self.pixels.clone(); - for px in out.as_chunks_mut::<4>().0 { - let a = px.get(3).copied().unwrap_or(255) as u32; - if a != 0 && a != 255 { - for c in px.iter_mut().take(3) { - *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; - } - } - } - out - } - /// One pixel as straight (unpremultiplied) RGBA; `None` outside the image. - pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> { - if x >= self.width || y >= self.height { - return None; - } - let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?; - let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?; - if a == 0 || a == 255 { - return Some([r, g, b, a]); - } - let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8; - Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a]) - } - pub fn to_png(&self) -> Vec { - let mut px = self.to_straight(); - px.resize(self.width as usize * self.height as usize * 4, 0); - let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { return Vec::new() }; - let mut buf = Vec::new(); - if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) { - log::error!("PNG encode: {e}"); - return Vec::new(); - } - buf - } - - /// PNG of this image whose `pHYs` chunk says `dpi` dots per inch. - /// - /// Slide space is points, 72 per inch, so a render at `scale` pixels per point is - /// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a - /// Retina (2×) export opens at twice the slide's size. - pub fn to_png_at(&self, dpi: f64) -> Vec { - images::stamp_png_dpi(&self.to_png(), dpi) - } - - pub fn to_jpeg(&self, quality: u8) -> Vec { - self.to_jpeg_at(quality, None) - } - - /// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range. - pub fn to_jpeg_at(&self, quality: u8, dpi: Option) -> Vec { - let straight = self.to_straight(); - let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); - let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; - let mut buf = Vec::new(); - let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); - if let Some(d) = dpi.and_then(dpi_u16) { - enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d)); - } - if let Err(e) = img.write_with_encoder(enc) { - log::error!("JPEG encode: {e}"); - return Vec::new(); - } - buf - } - /// Pixel at (x, y) as straight RGBA. - pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { - let i = (y as usize * self.width as usize + x as usize) * 4; - let p = self.pixels.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]); - let a = p.get(3).copied().unwrap_or(0) as u32; - let un = |c: u8| (c as u32 * 255 + a / 2).checked_div(a).map(|v| v.min(255) as u8).unwrap_or(0); - [un(p[0]), un(p[1]), un(p[2]), a as u8] - } -} - -/// Dots per inch for a slide drawn at `pixels_per_point`. -/// -/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi: -/// the same pixels, at the slide's real size, on a Retina display. -pub fn dpi_for_scale(pixels_per_point: f64) -> f64 { - if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { - return 72.0; - } - (pixels_per_point * 72.0).clamp(1.0, 10_000.0) -} - -fn dpi_u16(dpi: f64) -> Option { - if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) { - return None; - } - Some(dpi.round() as u16) -} - -/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch. -pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> { - images::dpi(bytes) -} - -/// Animation state of one shape during a slide show or preview. -#[derive(Clone, Debug)] -pub struct ShapeState { - pub visible: bool, - pub opacity: f64, - /// Applied in slide space around the shape's centre: translation (pt), scale, rotation (deg). - pub offset: Vec2, - pub scale: (f64, f64), - pub rotate: f64, - /// Reveal clip in shape-local unit space (0..1): l, t, r, b. - pub clip: Option<[f64; 4]>, - /// Colour override for emphasis effects (fill). - pub tint: Option, - /// Per-paragraph states of by-paragraph text builds (paragraph index, state). - pub paras: Vec<(usize, ParaState)>, -} - -impl Default for ShapeState { - fn default() -> Self { - ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] } - } -} - -/// Animation state of one paragraph, relative to its shape. -#[derive(Clone, Copy, Debug)] -pub struct ParaState { - pub visible: bool, - pub opacity: f64, - /// Translation in shape-local points. - pub offset: Vec2, -} - -impl Default for ParaState { - fn default() -> Self { - ParaState { visible: true, opacity: 1.0, offset: Vec2::ZERO } - } -} - -pub struct RenderOpts<'a> { - /// Pixels per point. - pub scale: f64, - /// Editor view: placeholder prompts and dashed placeholder outlines. - pub edit: bool, - /// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does. - pub show_hidden: bool, - /// Per-shape animation state. - pub state: Option<&'a dyn Fn(ShapeId) -> ShapeState>, - /// Don't draw these shapes (being edited in place, drawn by the UI). - pub skip: &'a [ShapeId], - /// Hide the text of this shape (the UI draws it while editing). - pub skip_text: Option, - /// Text being edited: replace its prompt with gray markers on empty list paragraphs. - /// Ignored outside editor rendering. - pub editing_text: Option, - /// Animated GIF pictures and how far into their play they are (seconds); others show frame 1. - pub gif_times: &'a [(ShapeId, f64)], - /// Worker threads (0 = this thread; required when effects use filters). - pub threads: u16, - /// Translate the slide within the output (pixels) and output size override. - pub offset: (f64, f64), - pub size: Option<(u32, u32)>, - /// Draw a background colour outside the slide (output larger than slide). - pub clear: Option, - /// Show this text in every date field instead of the date. - pub date_text: Option, - /// The local date and time date fields show (`None`: the system clock). - pub now: Option, - /// Leave the slide background out (neither the white underlay nor the background fill), so - /// only what is on the slide is drawn over transparency: PNG export with a transparent - /// background. Shapes filled with the background (`useBgFill`) still show it. - pub transparent_background: bool, -} - -impl Default for RenderOpts<'_> { - fn default() -> Self { - RenderOpts { - scale: 1.0, - edit: false, - show_hidden: false, - state: None, - skip: &[], - skip_text: None, - editing_text: None, - gif_times: &[], - threads: 0, - offset: (0.0, 0.0), - size: None, - clear: None, - date_text: None, - now: None, - transparent_background: false, - } - } -} - -struct SlideFields { - num: u32, - /// Text for every date field instead of the date ([`RenderOpts::date_text`]). - date: Option, - footer: String, - /// The local date and time date fields show; `None`: they keep their saved text. - now: Option, -} - -impl SlideFields { - fn new(num: u32, footer: String, opts: &RenderOpts) -> Self { - SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) } - } -} - -impl Fields for SlideFields { - fn field(&self, kind: &str) -> Option { - self.field_in(kind, None) - } - - fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { - if kind == "slidenum" { - return Some(self.num.to_string()); - } - if datetime::is_date_field(kind) { - // An automatic date: today's, in the format of its type. (A fixed date is plain text - // in the slide's placeholder, not a field.) - return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?)); - } - if kind == "footer" { - return Some(self.footer.clone()); - } - None - } -} - -pub struct Renderer { - resources: Resources, - /// Outlines morphing in the frame being drawn by `blend`. - paths: Vec, - /// 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, - /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw - /// under it combined with the group's child-coordinate mapping, which must not scale lines. - view: Affine, - /// The fill of the group being drawn, with the transform and box it is laid out in: children - /// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group. - group_fill: Option<(Fill, Affine, Rect)>, - /// Their aligned outlines, kept from frame to frame. - morphs: morph_path::Cache, - /// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group. - in_shadow_pass: bool, - /// Cumulative shape traversal counter to prevent nested group amplification DoS. - render_work: usize, - /// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget - /// of the normal drawing that follows. - shadow_work: usize, -} - -impl Default for Renderer { - fn default() -> Self { - Renderer { - resources: Resources::new(), - paths: Vec::new(), - bg: None, - drawn: None, - view: Affine::IDENTITY, - group_fill: None, - morphs: morph_path::Cache::default(), - in_shadow_pass: false, - render_work: 0, - shadow_work: 0, - } - } -} - -static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -fn note_panic() { - PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); -} - -/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. -pub fn panics_caught() -> u64 { - PANICS.load(std::sync::atomic::Ordering::Relaxed) -} - -thread_local! { - static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); -} - -/// Does anything on this slide (or its layout/master graphics) use blur-based effects? -pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { - let Some(rctx) = Ctx::for_slide(pres, slide) else { return false }; - let mut any = false; - let mut check = |shapes: &[Shape]| { - deckcraft_model::walk(shapes, &mut |s, _| { - if any { - return; - } - let (e, _) = resolve::effects(&rctx, s); - if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) { - any = true; - } - }); - }; - check(&slide.shapes); - if let Some(l) = rctx.layout { - check(&l.shapes); - } - check(&rctx.master.shapes); - any -} - -/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's -/// text styles it inherits)? Such text is blurred, which needs filters. -fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { - use deckcraft_model::text::{ListStyle, RunProps}; - let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0); - let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run)); - let Some(t) = &s.text else { return false }; - t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props)) - || styled(&t.list_style) - || styled(&rctx.master.title_style) - || styled(&rctx.master.body_style) - || styled(&rctx.master.other_style) -} - -/// 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 { - 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"); - note_panic(); - 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] }, page) - } - } -} - -/// Which part of a slide [`render_slide_part`] draws. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SlidePart { - /// The background fill with the master and layout graphics. - Backdrop, - /// The slide's own shapes over transparency. - Content, -} - -/// Render one part of slide `index` (transitions that move the contents over the backgrounds). -/// A panic inside the renderer becomes a transparent image (logged), never a crash. -pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, 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_part(pres, slide, index, part, opts)))) { - Ok(img) => img, - Err(_) => { - log::error!("rendering part of slide {index} panicked; showing nothing"); - note_panic(); - 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![0; w as usize * h as usize * 4] } - } - } -} - -/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the -/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by -/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`: -/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or -/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw -/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies -/// to `shapes` only. -pub fn render_blend( - pres: &Presentation, - old: usize, - new: usize, - mix: f64, - shapes: &[(Shape, bool)], - text: &[TextMorph], - paths: &[PathMorph], - opts: &RenderOpts, -) -> Image { - let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() }; - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts)) - })) { - Ok(img) => img, - Err(_) => { - log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); - note_panic(); - RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); - render_slide(pres, new, opts) - } - } -} - -/// Render a layout (Slide Master view thumbnail and canvas). -pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) { - Ok(img) => img, - Err(_) => { - log::error!("rendering a layout panicked; showing a blank image"); - note_panic(); - RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); - Image::default() - } - } -} - -fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) { - let (w, h) = opts - .size - .unwrap_or(((pres.slide_size.width * opts.scale).round().max(1.0) as u32, (pres.slide_size.height * opts.scale).round().max(1.0) as u32)); - (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16) -} - -pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color { - let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8; - peniko::Color::from_rgba8(c.r, c.g, c.b, a) -} - -fn shade(c: Rgba, m: FillMode) -> Rgba { - match m { - FillMode::Lighten => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.4), - FillMode::LightenLess => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.2), - FillMode::Darken => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.4), - FillMode::DarkenLess => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.2), - _ => c, - } -} - -/// Geometry of a shape at its size. -pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry { - match &shape.geom { - Geom::Preset { name, adj } => preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).unwrap_or(Geometry { - paths: vec![], - text_rect: Rect::new(0.0, 0.0, w, h), - handles: vec![], - sites: vec![], - }), - Geom::Custom { paths } => { - let mut out = vec![]; - for p in paths { - let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) }); - let mut bp = parse_path(&p.d); - bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph)); - // PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path - // inside another cuts a hole whichever way it winds, as in icons and outlined text. - out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true }); - } - Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] } - } - } -} - -/// Parse our custom path syntax (`M x y L x y C … Q … Z`). -pub fn parse_path(d: &str) -> BezPath { - let mut bp = BezPath::new(); - let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect(); - let mut i = 0; - let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); - let mut started = false; - while i < toks.len() { - let t = toks.get(i).copied().unwrap_or(""); - match t { - "M" => { - if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) { - bp.move_to((x, y)); - started = true; - } - i += 3; - } - "L" => { - if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) { - if !started { - bp.move_to((x, y)); - started = true; - } else { - bp.line_to((x, y)); - } - } - i += 3; - } - "C" => { - if let (Some(a), Some(b), Some(c), Some(d2), Some(e), Some(f)) = - (num(i + 1), num(i + 2), num(i + 3), num(i + 4), num(i + 5), num(i + 6)) - && started - { - bp.curve_to((a, b), (c, d2), (e, f)); - } - i += 7; - } - "Q" => { - if let (Some(a), Some(b), Some(c), Some(d2)) = (num(i + 1), num(i + 2), num(i + 3), num(i + 4)) - && started - { - bp.quad_to((a, b), (c, d2)); - } - i += 5; - } - "Z" | "z" => { - if started { - bp.close_path(); - } - i += 1; - } - _ => i += 1, - } - } - bp -} - -/// The transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine -/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts. -pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option { - if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { - return None; - } - let (e, _) = resolve::effects(rctx, s); - e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h)) -} - -/// The rectangle a picture's image occupies given its crop (fractions; negative = padding). -pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect { - let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(-10.0, 0.99) } else { 0.0 }); - let vis_w = (1.0 - l - r).max(1e-6); - let vis_h = (1.0 - t - b).max(1e-6); - let fw = bounds.width() / vis_w; - let fh = bounds.height() / vis_h; - Rect::new(bounds.x0 - l * fw, bounds.y0 - t * fh, bounds.x0 - l * fw + fw, bounds.y0 - t * fh + fh) -} - -struct Frame<'a> { - pres: &'a Presentation, - opts: &'a RenderOpts<'a>, - fields: SlideFields, -} - -impl Renderer { - fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { - let (w, h) = output_size(pres, opts); - let mut ctx = RenderContext::new_with( - w, - h, - vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() }, - ); - if let Some(c) = opts.clear { - ctx.set_paint(color(c, 1.0)); - ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); - } - let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); - (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) - } - - /// Background plus the master and layout graphics the slide shows. - fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { - self.background(ctx, f, rctx, Some(slide), view); - let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); - if show_master { - let mctx = Ctx::for_master(f.pres, rctx.master); - for s in &rctx.master.shapes { - if s.ph.is_none() { - self.shape(ctx, f, &mctx, s, view, 0); - } - } - } - if show_layout && let Some(l) = rctx.layout { - let lctx = Ctx::for_layout(f.pres, rctx.master, l); - for s in &l.shapes { - if s.ph.is_none() { - self.shape(ctx, f, &lctx, s, view, 0); - } - } - } - } - - fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); - let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; - if let Some(rctx) = Ctx::for_slide(pres, slide) { - self.backdrop(&mut ctx, &f, &rctx, slide, view); - for s in &slide.shapes { - self.shape(&mut ctx, &f, &rctx, s, view, 0); - } - } - self.finish(ctx, w, h) - } - - fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); - let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; - if let Some(rctx) = Ctx::for_slide(pres, slide) { - match part { - SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view), - SlidePart::Content => { - // Shapes filled with the background (`useBgFill`) still show it. - let (fill, ph) = resolve::background(&rctx, Some(slide)); - self.bg = Some((fill, ph, view)); - for s in &slide.shapes { - self.shape(&mut ctx, &f, &rctx, s, view, 0); - } - } - } - } - self.finish(ctx, w, h) - } - - #[allow(clippy::too_many_arguments)] - fn blend( - &mut self, - pres: &Presentation, - old: (&Slide, usize), - new: (&Slide, usize), - mix: f64, - shapes: &[(Shape, bool)], - text: &[TextMorph], - paths: &[PathMorph], - opts: &RenderOpts, - ) -> Image { - let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); - let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; - // Backdrop graphics never take shape states: their ids may clash with the frame's shapes. - let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts }; - if mix < 1.0 { - let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) }; - self.backdrop(&mut ctx, &f, &co, old.0, view); - } - if mix > 0.0 { - let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) }; - let layered = mix < 1.0; - if layered { - ctx.push_layer(None, None, Some(mix as f32), None, None); - } - self.backdrop(&mut ctx, &f, &cn, new.0, view); - if layered { - ctx.pop_layer(); - } - } - let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) }; - let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) }; - let mut drawn: Vec = Vec::new(); - // Set only now: the backdrop's ids may clash with the frame's. - self.paths = paths.to_vec(); - for (s, from_old) in shapes { - let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) }; - let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else { - self.shape(&mut ctx, f, c, s, view, 0); - continue; - }; - self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0); - drawn.push(s.id); - if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) { - continue; - } - let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id); - if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) { - let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b)); - morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view); - } - } - self.paths.clear(); - self.morphs.sweep(); - self.finish(ctx, w, h) - } - - fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (w, h) = output_size(pres, opts); - let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); - let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) }; - if let Some(m) = pres.masters.get(mi) { - match li.and_then(|i| m.layouts.get(i)) { - Some(l) => { - let c = Ctx::for_layout(pres, m, l); - self.background(&mut ctx, &f, &c, None, view); - if l.show_master_shapes { - let mc = Ctx::for_master(pres, m); - for s in m.shapes.iter().filter(|s| s.ph.is_none()) { - self.shape(&mut ctx, &f, &mc, s, view, 0); - } - } - for s in &l.shapes { - self.shape(&mut ctx, &f, &c, s, view, 0); - } - } - None => { - let c = Ctx::for_master(pres, m); - self.background(&mut ctx, &f, &c, None, view); - for s in &m.shapes { - self.shape(&mut ctx, &f, &c, s, view, 0); - } - } - } - } - self.finish(ctx, w, h) - } - - fn finish(&mut self, mut ctx: RenderContext, w: u16, h: u16) -> Image { - ctx.flush(); - let mut pixels = vec![0u8; w as usize * h as usize * 4]; - if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) { - ctx.render(pm, &mut self.resources); - } - Image { width: w as u32, height: h as u32, pixels } - } - - fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { - let (fill, ph) = resolve::background(rctx, slide); - if f.opts.transparent_background { - self.bg = Some((fill, ph, view)); - return; - } - let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); - ctx.set_transform(view); - let path = r.to_path(0.1); - // PowerPoint lays every background over white: a picture stretched into part of the - // slide, or a see-through colour, leaves white around and under it. - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.fill_path(&path); - paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); - self.bg = Some((fill, ph, view)); - } - - fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { - if depth == 0 { - let outer = std::mem::replace(&mut self.view, parent); - self.shape_at(ctx, f, rctx, s, parent, depth); - self.view = outer; - } else { - self.shape_at(ctx, f, rctx, s, parent, depth); - } - } - - /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the - /// one that takes the shape's outline into it. Inside a group they are the slide-level view and - /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but - /// never the line width; outside a group, `m` itself and no change to the outline. - fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) { - let view = self.view; - if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } - } - - /// Shape-space units per point: a group's child coordinates can scale a shape's box (a legacy - /// 1360-unit child space makes one unit hundreds of EMUs), but 3-D sizes kept in points - /// (contour width, extrusion depth, bevel) stay points, as PowerPoint draws them. 1 outside a - /// group. - fn per_point(&self, m: Affine, depth: usize) -> f64 { - let k = scale_of(self.line_space(m, depth).1); - if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } - } - - /// The size and transform a shape's text is set in: inside a group whose child coordinates - /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so - /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. - fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) { - let (_, rel) = self.line_space(m, depth); - let c = rel.as_coeffs(); - let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3])); - let ok = |k: f64| k.is_finite() && k > 1e-9; - if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) { - return (w, h, m); - } - let (tw, th) = (w * sx, h * sy); - if !tw.is_finite() || !th.is_finite() { - return (w, h, m); - } - (tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy)) - } - - /// A shape's geometry in its own `w` × `h` space, built at its size on the slide: inside a - /// group whose child coordinates scale the shape unevenly, the preset's rounded corners and - /// other size-derived features stay as they would be on a plain shape of that size (#1083). - fn scaled_geometry(&self, s: &Shape, m: Affine, depth: usize, w: f64, h: f64) -> Geometry { - let (tw, th, _) = self.text_space(m, depth, w, h); - if (tw, th) == (w, h) { - return shape_geometry(s, w, h); - } - let mut geo = shape_geometry(s, tw, th); - let k = |a: f64, b: f64| if b.abs() > 1e-12 { a / b } else { 1.0 }; - let back = Affine::scale_non_uniform(k(w, tw), k(h, th)); - for p in &mut geo.paths { - p.path.apply_affine(back); - } - geo.text_rect = back.transform_rect_bbox(geo.text_rect); - for p in &mut geo.sites { - *p = back * *p; - } - geo - } - - fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { - if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { - return; - } - let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work }; - *work = work.saturating_add(1); - if *work > 100_000 { - return; - } - // Footer placeholders on layouts/masters show only on slides that include them. - let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default(); - if !st.visible || st.opacity <= 0.001 { - return; - } - let x = resolve::xfrm(rctx, s); - if !x.is_finite() { - return; - } - let mut m = parent * x.affine(); - if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 { - let c = Point::new(x.w / 2.0, x.h / 2.0); - let around = Affine::translate(c.to_vec2()) - * Affine::rotate(st.rotate.to_radians()) - * Affine::scale_non_uniform(st.scale.0, st.scale.1) - * Affine::translate(-c.to_vec2()); - m = parent * Affine::translate(st.offset) * x.affine() * around; - } - if let Some(a) = camera_turn(rctx, s, x.w, x.h) { - m *= a; - } - let layered = st.opacity < 0.999 || st.clip.is_some(); - if layered { - ctx.set_transform(m); - let clip = st.clip.map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1)); - ctx.push_layer(clip.as_ref(), None, Some(st.opacity.clamp(0.0, 1.0) as f32), None, None); - } - match &s.kind { - ShapeKind::Group { children, child } => { - let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); - let gm = parent * deckcraft_geom::group_child_affine(&x, ch); - // A group's own fill (a group filled with its parent's passes that on). - let own = match &s.fill { - Some(Fill::Group) | None => self.group_fill.clone(), - Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), - }; - let outer = std::mem::replace(&mut self.group_fill, own); - // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the - // children drawn once more under the shadow's offset, scale and skew, recoloured to - // the shadow colour and blurred, below the group. - if !self.in_shadow_pass - && let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) - { - let c = rctx.color(&sh.color, None); - let k = scale_of(m); - let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - let local = shadow_local(sh, x.w, x.h); - let (own, pre) = - if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; - let det = m.determinant(); - if det.is_finite() && det.abs() > 1e-12 { - let shift = pre * m * own * m.inverse(); - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); - self.in_shadow_pass = true; - for ch in children { - self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); - } - self.in_shadow_pass = false; - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); - ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); - ctx.pop_layer(); - ctx.pop_layer(); - } - } - for c in children { - self.shape(ctx, f, rctx, c, gm, depth + 1); - } - self.group_fill = outer; - } - 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, f.opts.edit && f.opts.editing_text == Some(s.id)); - 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 legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); - let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; - for (px, py, pr) in &sk.points { - let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); - self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); - } - let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); - if pts.len() < 2 { - continue; - } - let (base, rel) = self.line_space(parent, depth); - ctx.set_transform(base); - ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); - // Uniform-width and highlighter strokes are one path: segments drawn one by one - // overlap at the joins, which darkens see-through highlighter ink in beads. - if legacy || sk.highlighter { - let mut bp = BezPath::new(); - for (i, (px, py, _)) in pts.iter().enumerate() { - if i == 0 { - bp.move_to((*px, *py)); - } else { - bp.line_to((*px, *py)); - } - } - bp.apply_affine(rel); - ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); - continue; - } - for pair in pts.windows(2) { - let [p0, p1] = pair else { continue }; - let mut bp = BezPath::new(); - bp.move_to((p0.0, p0.1)); - bp.line_to((p1.0, p1.1)); - bp.apply_affine(rel); - let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy); - let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy); - ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); - } - } - } - _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), - } - if layered { - ctx.pop_layer(); - } - } - - /// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per - /// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill` - /// the group's fill. - #[allow(clippy::too_many_arguments)] - fn fill_geometry( - &self, - ctx: &mut RenderContext, - f: &Frame, - rctx: &Ctx, - fl: &Fill, - fill_ph: Option, - geo: &Geometry, - (w, h): (f64, f64), - m: Affine, - fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, - ) { - for (i, sp) in geo.paths.iter().enumerate() { - if sp.fill == FillMode::None || fade(i).fill <= 0.0 { - continue; - } - ctx.set_transform(m); - ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); - match (fl, &self.bg) { - // `useBgFill`: the slide's background, laid out on the slide (not the - // shape), shows through the shape. - (Fill::Background, Some((bg, ph, view))) => { - let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); - let path = (view.inverse() * m) * sp.path.clone(); - paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres); - } - // `grpFill`: the group's fill, laid out over the whole group. - (Fill::Group, _) => { - if let Some((gf, gm, gbox)) = &self.group_fill - && gm.determinant().abs() > 1e-12 - { - let path = (gm.inverse() * m) * sp.path.clone(); - paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); - } - } - _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), - } - ctx.set_fill_rule(peniko::Fill::NonZero); - } - } - - #[allow(clippy::too_many_arguments)] - fn geometry_shape( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - rctx: &Ctx, - s: &Shape, - m: Affine, - w: f64, - h: f64, - st: &ShapeState, - depth: usize, - ) { - let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { - Some(pm) => self.morphs.geometry(pm, s, w, h), - None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), - }; - let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); - 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. - let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); - let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; - // Effects below the shape. - if let Some(e) = &effects { - // A picture (or picture fill) casts its shadow from its pixels, not its frame. - let content = match (&s.kind, &fill) { - (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), - _ => None, - }; - // What casts the shadow: the fill as see-through as it is, and the outline. - let fill_alpha = fill_opacity(rctx, &fill, fill_ph); - let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { - let a = match lf { - Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, - _ => 1.0, - }; - let (base, rel) = self.line_space(m, depth); - let lw = line.width.unwrap_or(0.75).max(0.0); - let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; - Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } - }); - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); - } - let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); - if let Some(r) = soft { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, None, None, None); - let _ = r; - } - // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which - // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. - let pt = { - let turn = effects - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, w, h)) - .filter(|t| t.determinant().abs() > 1e-9); - self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) - }; - // A 3-D extrusion under a turned camera: the sides, swept behind the face. - if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) - && let Some(ex) = extrude::from_raw(raw) - && let Some(cam) = camera::from_raw(raw) - && let Some(turn) = camera::affine(&cam, w, h) - && turn.determinant().abs() > 1e-9 - { - let face = match &fill { - Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), - _ => None, - }; - let pre = m * turn.inverse(); - let lit = light::Lighting::from_raw(raw); - let d = ex.depth * pt; - extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); - } - // A 3-D contour: a band around the outline, behind the face (and in front of any sides). - if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) - && let Some(c) = extrude::contour_from_raw(raw) - { - let face = match &fill { - Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)), - _ => None, - }; - let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); - extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); - } - // Fill. - match &s.kind { - ShapeKind::Picture { fill: pf } => { - // The picture's own fill shows behind its transparent pixels, as in PowerPoint (an - // emphasis tint recolours it, as it does any shape's fill). - if s.fill.as_ref().is_some_and(|f| !f.is_none()) - && let Some(fl) = &fill - { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); - } - ctx.set_transform(m); - let bounds = Rect::new(0.0, 0.0, w, h); - ctx.push_clip_layer(&outline); - let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); - ctx.pop_layer(); - } - // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its - // preview picture, stretched over the frame, as PowerPoint draws it. - ShapeKind::Opaque { preview: Some(media), .. } => { - // An embedded object's fallback picture can carry a fill (old equation objects - // drawn on coloured boxes): behind the preview, as for a picture. - if s.fill.as_ref().is_some_and(|f| !f.is_none()) - && let Some(fl) = &fill - { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); - } - ctx.set_transform(m); - let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; - paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None); - } - ShapeKind::Media(mc) => { - ctx.set_transform(m); - let bounds = Rect::new(0.0, 0.0, w, h); - if let Some(p) = mc.poster { - let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; - ctx.push_clip_layer(&outline); - paint::picture(ctx, f.pres, &pf, bounds, m, None); - ctx.pop_layer(); - } else if mc.video { - ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255)); - ctx.fill_rect(&bounds); - } - if !mc.video || mc.poster.is_none() { - paint::media_glyph(ctx, mc.video, bounds); - } - } - _ => { - if let Some(fl) = &fill - && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) - { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); - } - } - } - // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply - // a gradient over a tiled picture), inside the filled outline, under the line. - if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) - && fill.as_ref().is_some_and(|f| !f.is_none()) - { - let mix = match ov.blend.as_str() { - "mult" => peniko::Mix::Multiply, - "screen" => peniko::Mix::Screen, - "darken" => peniko::Mix::Darken, - "lighten" => peniko::Mix::Lighten, - _ => peniko::Mix::Normal, - }; - for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) { - ctx.set_transform(m); - ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); - paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); - ctx.pop_layer(); - } - } - // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). - let line = match (&s.geom, line.join) { - (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { - Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } - } - _ => line, - }; - // A 3-D bevel: the face and a band inside the outline lit as surfaces. - if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { - let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; - let solid = match &fill { - Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), - _ => None, - }; - let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none()); - if filled { - let mut area = BezPath::new(); - for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { - area.extend(sp.path.iter()); - } - if is_pic || area.elements().is_empty() { - area = outline.clone(); - } - bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); - } - } - // Outline. - if line.fill.as_ref().is_some_and(|f| !f.is_none()) { - let lw = line.width.unwrap_or(0.75).max(0.0); - let (base, rel) = self.line_space(m, depth); - // Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed - // outlines or on presets like brackets. - let ends = s.is_line() - || geo.is_open() - || (matches!(s.geom, Geom::Custom { .. }) - && geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)))); - let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) }; - let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none")); - // A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into - // a layer that is then faded, so they don't darken where they meet. - let see_through = match &line.fill { - Some(Fill::Solid { color: c }) if has_heads => Some(rctx.color(c, line_ph)).filter(|c| c.a < 255), - _ => None, - }; - let line = match see_through { - Some(c) => { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None); - std::borrow::Cow::Owned(Line { - fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), - ..line.clone() - }) - } - None => std::borrow::Cow::Borrowed(&line), - }; - // A picture's border lies outside the picture, as PowerPoint draws it: stroked twice - // as wide, outside the outline only (see `border_outside`). - let outside = border_outside(is_pic, effects.as_ref()).then(|| outside_line(&line, lw)).flatten(); - let clipped = outside.is_some(); - let (line, lw) = match outside { - Some(o) => { - let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1); - clip.extend(outline.iter()); - ctx.set_transform(m); - ctx.set_fill_rule(peniko::Fill::EvenOdd); - ctx.push_clip_layer(&clip); - ctx.set_fill_rule(peniko::Fill::NonZero); - (std::borrow::Cow::Owned(o), lw * 2.0) - } - None => (line, lw), - }; - let first_stroked = geo.paths.iter().position(|p| p.stroke); - for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { - let mut path = if rel == Affine::IDENTITY { - std::borrow::Cow::Borrowed(&sp.path) - } else { - let mut moved = sp.path.clone(); - moved.apply_affine(rel); - std::borrow::Cow::Owned(moved) - }; - // The line stops inside its arrowheads, so a wide line doesn't poke out of them. - let whole = path.clone(); - if Some(i) == first_stroked && trims != (0.0, 0.0) { - path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1)); - } - stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); - } - if ends { - arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); - } - if clipped { - ctx.pop_layer(); - } - if see_through.is_some() { - ctx.pop_layer(); - } - } - if let Some(r) = soft { - // Feather: the shape (fill and line) fades to nothing at its outline. Measured on - // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of - // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. - ctx.set_transform(m); - ctx.push_layer( - None, - Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), - None, - None, - Some(blur(r / 3.0 * scale_of(m))), - ); - ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - ctx.fill_path(&outline); - ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); - ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); - ctx.stroke_path(&outline); - ctx.pop_layer(); - ctx.pop_layer(); - ctx.pop_layer(); - } - // Inner shadow. - if let Some(e) = &effects - && let Some(sh) = &e.inner_shadow - { - ctx.set_transform(m); - ctx.push_clip_layer(&outline); - let c = rctx.color(&sh.color, fx_ph); - let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - // As PowerPoint draws it (measured on its export): the shadow falls inside along the - // edges it points to (45 degrees: the bottom and right), blurred by a third of the - // blur radius. - ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m)))); - ctx.set_paint(color(c, 1.0)); - let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1); - let mut hole = outline.clone(); - hole.apply_affine(Affine::translate(-d)); - ring.extend(hole.iter()); - ctx.set_fill_rule(peniko::Fill::EvenOdd); - ctx.fill_path(&ring); - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.pop_layer(); - ctx.pop_layer(); - } - // Text (or the placeholder prompt in the editor). - let skip_text = f.opts.skip_text == Some(s.id); - if let Some(body) = &s.text - && !skip_text - { - // Text in a group is set at the shape's size on the slide, in points: a group's - // child-coordinate scale sizes and places the box, never the type (#233's twin). - let (tw, th, m) = self.text_space(m, depth, w, h); - let text_geo; - let geo = if (tw, th) == (w, h) { - &geo - } else { - text_geo = shape_geometry(s, tw, th); - &text_geo - }; - let tr = geo.text_rect; - let editing = f.opts.edit && f.opts.editing_text == Some(s.id); - if !body.is_empty() || editing { - // Imported placeholders can have no paragraph until the first keystroke. - let mut empty; - let body = if editing && body.paragraphs.is_empty() { - empty = body.clone(); - empty.paragraphs.push(Default::default()); - &empty - } else { - body - }; - let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); - if editing { - preview_empty_bullets(&mut l); - } - 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), - } - } else if f.opts.edit && s.ph.is_some() { - let kind = s.ph_type().unwrap_or(PhType::Body); - let prompt_body = prompt_body(rctx, s, kind); - if !prompt_body.is_empty() { - let grey = Rgba::rgb(0x59, 0x59, 0x59); - draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); - } - } - } - if empty_ph && f.opts.edit { - // Dashed placeholder outline. - ctx.set_transform(m); - let px = 1.0 / scale_of(m).max(1e-6); - ctx.set_paint(peniko::Color::from_rgba8(0xA6, 0xA6, 0xA6, 255)); - ctx.set_stroke(kurbo::Stroke::new(px).with_dashes(0.0, [3.0 * px, 2.0 * px])); - ctx.stroke_path(&Rect::new(0.0, 0.0, w, h).to_path(0.1)); - if matches!(s.ph_type(), Some(PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram)) { - paint::placeholder_icon(ctx, s.ph_type().unwrap_or(PhType::Picture), Rect::new(0.0, 0.0, w, h)); - } - } - } - - fn effects_below( - &mut self, - ctx: &mut RenderContext, - rctx: &Ctx, - e: &Effects, - ph: Option, - geo: &Geometry, - fill: &Option, - fill_alpha: Option, - outline: Option, - content: Option<&deckcraft_model::style::PictureFill>, - m: Affine, - w: f64, - h: f64, - ) { - let silhouette = { - let mut p = BezPath::new(); - for sp in &geo.paths { - if sp.fill != FillMode::None || fill.is_none() { - p.extend(sp.path.iter()); - } - } - if p.elements().is_empty() { geo.outline() } else { p } - }; - let k = scale_of(m); - if let Some(sh) = &e.outer_shadow { - let c = rctx.color(&sh.color, ph); - let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - let local = shadow_local(sh, w, h); - // Shadow offset is in slide space (doesn't rotate with the shape unless asked). - let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; - let sm = pre * m * own; - ctx.set_transform(Affine::IDENTITY); - // Blurred by a third of the blur radius, as PowerPoint's export shows. - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); - ctx.set_transform(sm); - match content { - // As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a - // transparent background casts the logo's shadow): the picture, clipped to the - // shape, recoloured to the shadow colour. - Some(pf) => { - ctx.push_clip_layer(&silhouette); - paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None); - // Drawing the picture leaves its own pixel transform set: back to the shape's, - // or the recolouring below covers only part of a large picture's shadow. - ctx.set_transform(sm); - ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0)); - ctx.pop_layer(); - ctx.pop_layer(); - } - // The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as - // see-through as its fill (none for an outline-only shape) … - None => { - if let Some(a) = fill_alpha { - let mut filled = BezPath::new(); - for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { - filled.extend(sp.path.iter()); - } - ctx.set_paint(color(c.with_alpha(255), a)); - ctx.fill_path(&filled); - } - } - } - // … and the outline (a picture's border too). - if let Some(o) = &outline { - let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() }; - let lw = o.line.width.unwrap_or(0.75).max(0.0); - for sp in geo.paths.iter().filter(|sp| sp.stroke) { - let mut path = sp.path.clone(); - path.apply_affine(o.rel * own); - stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha); - } - } - ctx.pop_layer(); - } - // A shape with no fill and no line (a text box) draws nothing to glow around: its text's - // own glow is all PowerPoint shows, not a halo round the box. - let glows = fill_alpha.is_some() || content.is_some() || outline.is_some(); - if let Some(g) = e.glow.as_ref().filter(|_| glows) { - let c = rctx.color(&g.color, ph); - ctx.set_transform(Affine::IDENTITY); - // Measured on PowerPoint's export: a glow of radius r is half strength about half of r - // out from the outline and gone by r. - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k))); - ctx.set_transform(m); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round)); - ctx.stroke_path(&silhouette); - // A shape without a fill (an outline, a frame) glows along its line only (#225). - if fill_alpha.is_some() || content.is_some() { - ctx.fill_path(&silhouette); - } - ctx.pop_layer(); - } - if let Some(rf) = &e.reflection { - // Mirror below the shape, fading out. - let mm = m * Affine::translate((0.0, 2.0 * h + rf.dist)) * Affine::scale_non_uniform(1.0, -1.0); - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6), None, Some(blur(rf.blur * 0.5 * k))); - ctx.set_transform(mm); - match content { - // A picture reflects its pixels (it has no fill of its own). - Some(pf) => { - ctx.push_clip_layer(&silhouette); - paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), mm, None); - ctx.pop_layer(); - } - None => { - if let Some(fl) = fill { - paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); - } - } - } - ctx.set_transform(mm); - // Fade mask. - ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None); - let grad = peniko::Gradient::new_linear(Point::new(0.0, h), Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0)))) - .with_stops([(0.0, peniko::Color::from_rgba8(0, 0, 0, 255)), (1.0, peniko::Color::from_rgba8(0, 0, 0, 0))].as_slice()); - ctx.set_paint(grad); - ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(4.0, 4.0)); - ctx.pop_layer(); - ctx.pop_layer(); - } - } -} - -/// Whether a shape's border is drawn outside its outline: a picture's is, unless the picture is -/// drawn in 3-D (a bevel seen through a 3-D scene), where PowerPoint centres it on the edge as on -/// any shape (observed: an 18 pt border covers 9 pt of the picture then). -fn border_outside(is_pic: bool, effects: Option<&Effects>) -> bool { - is_pic && !effects.is_some_and(|e| e.bevel.is_some() && e.raw3d.as_deref().is_some_and(|r| r.contains("scene3d"))) -} - -/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside -/// the outline is the border, with dashes kept their length. `None` for compound lines, whose -/// bands would not survive the doubling (they stay centred on the outline). -fn outside_line(line: &Line, lw: f64) -> Option { - if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) { - return None; - } - let dash = line.dash.as_ref().map(|d| { - let p = d.pattern(); - deckcraft_model::style::Dash::Custom { - pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(), - } - }); - Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() }) -} - -/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity, -/// and the space it is stroked in (see `Renderer::line_space`). -struct Caster<'a> { - line: std::borrow::Cow<'a, Line>, - ph: Option, - alpha: f64, - base: Affine, - rel: Affine, -} - -/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a -/// gradient's mean stop alpha, otherwise opaque. -fn fill_opacity(rctx: &Ctx, fill: &Option, ph: Option) -> Option { - match fill { - None | Some(Fill::None) => None, - Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0), - Some(Fill::Gradient(g)) if !g.stops.is_empty() => { - Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::() / g.stops.len() as f64) - } - Some(_) => Some(1.0), - } -} - -/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point -/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the -/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow -/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its -/// middle. -fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine { - let (ax, ay) = match sh.align.as_str() { - "tl" => (0.0, 0.0), - "t" => (0.5, 0.0), - "tr" => (1.0, 0.0), - "l" => (0.0, 0.5), - "r" => (1.0, 0.5), - "bl" => (0.0, 1.0), - "b" => (0.5, 1.0), - "br" => (1.0, 1.0), - _ => (0.5, 0.5), - }; - let a = Vec2::new(w * ax, h * ay); - let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan()); - let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]); - if !k.is_finite() { - return Affine::IDENTITY; - } - Affine::translate(a) * k * Affine::translate(-a) -} - -fn pre_translate(m: Affine, d: Vec2) -> Affine { - Affine::translate(d) * m -} - -pub(crate) fn scale_of(m: Affine) -> f64 { - m.determinant().abs().sqrt() -} - -fn blur(sigma: f64) -> Filter { - Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) -} - -/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the -/// line belongs to; `path` itself when `None`). -#[allow(clippy::too_many_arguments)] -pub(crate) fn stroke( - ctx: &mut RenderContext, - rctx: &Ctx, - line: &Line, - ph: Option, - path: &BezPath, - extent: Option<&BezPath>, - m: Affine, - lw: f64, - alpha: f64, -) { - use deckcraft_model::style::{LineCap, LineJoin}; - let cap = match line.cap.unwrap_or_default() { - LineCap::Flat => kurbo::Cap::Butt, - LineCap::Round => kurbo::Cap::Round, - LineCap::Square => kurbo::Cap::Square, - }; - let join = match line.join.unwrap_or_default() { - LineJoin::Round => kurbo::Join::Round, - LineJoin::Bevel => kurbo::Join::Bevel, - LineJoin::Miter => kurbo::Join::Miter, - }; - // Hairlines stay visible at any zoom. - let min_w = 0.5 / scale_of(m).max(1e-6); - let sw = lw.max(min_w); - // PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`). - let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0); - // Square caps on a dashed line square off only the line's own two ends (where a dash is - // drawn): each dash itself ends flat, as PowerPoint draws them. - let mut square_ends = None; - if let Some(d) = &line.dash { - let pat: Vec = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); - let pat = dash_with_caps(pat, cap, sw); - let pat = limit_dash_count(path, pat); - if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) { - if cap == kurbo::Cap::Square { - sk = sk.with_caps(kurbo::Cap::Butt); - square_ends = Some(square_dash_ends(path, &pat, sw)); - } - sk = sk.with_dashes(0.0, pat); - } - } - ctx.set_transform(m); - match &line.fill { - Some(Fill::Solid { color: c }) => { - ctx.set_paint(color(rctx.color(c, ph), alpha)); - } - Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)), - _ => ctx.set_paint(color(Rgba::BLACK, alpha)), - } - // Compound lines: each band stroked along the path moved to its place across the width. - let compound = line.compound.unwrap_or_default(); - if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 { - let tolerance = 0.1 / scale_of(m).max(1e-6); - for &(a, b) in compound::bands(compound) { - let d = (-0.5 + (a + b) / 2.0) * lw; - let mut band = sk.clone(); - band.width = (b - a) * lw; - ctx.set_stroke(band); - ctx.stroke_path(&compound::offset(path, d, tolerance)); - } - return; - } - ctx.set_stroke(sk); - ctx.stroke_path(path); - if let Some(ends) = square_ends { - ctx.fill_path(&ends); - } -} - -/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square -/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end -/// when the pattern is in a dash there. -fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { - use kurbo::{ParamCurve, ParamCurveArclen}; - let mut out = BezPath::new(); - let period: f64 = pattern.iter().sum(); - let mut cap = |at: Point, dir: Vec2| { - let n = Vec2::new(-dir.y, dir.x) * (width / 2.0); - let back = dir * (width / 2.0); - out.move_to(at + n); - out.line_to(at + n + back); - out.line_to(at - n + back); - out.line_to(at - n); - out.close_path(); - }; - let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| { - let d = seg.eval(t1) - seg.eval(t0); - let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() }; - if d.hypot() > 1e-9 { Some(d.normalize()) } else { None } - }; - let mut subs: Vec<(Vec, bool)> = vec![]; - let mut cur: Vec = vec![]; - let mut closed = false; - let mut last = Point::ZERO; - let mut start = Point::ZERO; - for el in path.elements() { - let seg = match *el { - PathEl::MoveTo(p) => { - if !cur.is_empty() { - subs.push((std::mem::take(&mut cur), closed)); - } - closed = false; - last = p; - start = p; - continue; - } - PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), - PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), - PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), - PathEl::ClosePath => { - closed = true; - last = start; - continue; - } - }; - last = seg.end(); - cur.push(seg); - } - if !cur.is_empty() { - subs.push((cur, closed)); - } - for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) { - let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue }; - if let Some(d) = dir(first, 1e-3, 0.0) { - cap(first.start(), d); - } - let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum(); - if !(period > 0.0 && len.is_finite()) { - continue; - } - let mut pos = len % period; - // Ending exactly on a period boundary is the end of a gap. - let mut on = pos > period * 1e-6; - for v in pattern.iter().cycle().take(pattern.len()) { - if pos <= *v { - break; - } - pos -= v; - on = !on; - } - if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) { - cap(end.end(), d); - } - } - out -} - -/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the -/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs -/// across its width. -fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient { - let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0); - let bounds = paint::tile_box(bounds, g.tile); - let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect(); - paint::gradient(&g.shape, stops, bounds, alpha) -} - -/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As -/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a -/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid -/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a -/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see -/// `square_dash_ends`.) -fn dash_with_caps(mut pat: Vec, cap: kurbo::Cap, width: f64) -> Vec { - if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) { - return pat; - } - for (i, v) in pat.iter_mut().enumerate() { - *v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width }; - } - pat -} - -/// The most dashes one stroke is cut into. Stroking expands every dash into its own outline, so a -/// tiny dash on a huge shape (a 1 EMU-wide `custDash` on a 2147483647 EMU rectangle) would -/// otherwise need gigabytes and abort the app when the slide is drawn. Past this count the -/// pattern is scaled up evenly, which looks the same: the dashes are far below a pixel by then. -const MAX_DASHES: f64 = 20_000.0; - -fn limit_dash_count(path: &BezPath, pat: Vec) -> Vec { - use kurbo::ParamCurveArclen; - let period: f64 = pat.iter().sum(); - if !(period.is_finite() && period > 0.0) { - return pat; - } - let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); - let dashes = len / period; - if !len.is_finite() { - return vec![]; - } - if dashes <= MAX_DASHES { - return pat; - } - let k = dashes / MAX_DASHES; - pat.into_iter().map(|v| v * k).collect() -} - -fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { - let els: Vec = path.elements().to_vec(); - let mut pts: Vec = vec![]; - for e in &els { - match e { - PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(*p), - PathEl::QuadTo(a, b) => { - pts.push(*a); - pts.push(*b); - } - PathEl::CurveTo(a, b, c) => { - pts.push(*a); - pts.push(*b); - pts.push(*c); - } - PathEl::ClosePath => {} - } - } - if pts.len() < 2 { - return None; - } - let first = *pts.first()?; - let second = pts.iter().skip(1).find(|p| (**p - first).hypot() > 1e-6).copied().unwrap_or(*pts.get(1)?); - let last = *pts.last()?; - let prev = pts.iter().rev().skip(1).find(|p| (**p - last).hypot() > 1e-6).copied().unwrap_or(*pts.get(pts.len() - 2)?); - Some(((first, second), (last, prev))) -} - -/// An arrowhead's width and length for a line `lw` wide. PowerPoint sizes them in multiples of -/// the line width (small 2, medium 3, large 5), but never smaller than for a 2 pt line. -fn head_size(e: &deckcraft_model::style::LineEnd, lw: f64) -> (f64, f64) { - let sz = |s: &str| match s { - "sm" => 2.0, - "lg" => 5.0, - _ => 3.0, - }; - let k = lw.max(2.0); - (sz(&e.w) * k, sz(&e.len) * k) -} - -/// How far back from the tip an open arrowhead's legs (`lw` wide) meet along their centre lines: -/// PowerPoint puts the outside of the point, not the legs' centre lines, on the line's end. -fn open_arrow_setback(wl: f64, ll: f64, lw: f64) -> f64 { - let half = (wl / 2.0).atan2(ll); - if half.sin() > 1e-6 { (lw / 2.0 / half.sin()).min(ll) } else { 0.0 } -} - -/// How much of a `lw`-wide line to leave off at its start and end, under its arrowheads: up to -/// a triangle's base, into a stealth arrow's notch far enough for its barbs to cover the line's -/// corners, and to where an open arrow's legs meet. Ovals and diamonds sit on the end itself. -fn head_trims(line: &Line, lw: f64) -> (f64, f64) { - // A square cap reaches half a width past the line's end: leave that off too. - let cap = if line.cap == Some(deckcraft_model::style::LineCap::Square) { lw / 2.0 } else { 0.0 }; - let trim = |e: &Option| { - let Some(e) = e else { return 0.0 }; - let (wl, ll) = head_size(e, lw); - match e.kind.as_str() { - "triangle" => ll + cap, - "stealth" => (ll * (0.6 + 0.4 * lw / wl.max(1e-9))).min(ll) + cap, - "arrow" => open_arrow_setback(wl, ll, lw.max(0.75)) + cap, - _ => 0.0, - } - }; - (trim(&line.head), trim(&line.tail)) -} - -/// `path` with `start` left off the beginning of its first sub-path and `end` off the end of its -/// last (by arc length), where those are open. A sub-path shorter than its trims disappears. -fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath { - use kurbo::{ParamCurve, ParamCurveArclen}; - const ACC: f64 = 1e-3; - let mut subs: Vec<(Vec, bool)> = vec![]; - let (mut last, mut first) = (Point::ZERO, Point::ZERO); - for el in path.elements() { - let seg = match *el { - PathEl::MoveTo(p) => { - subs.push((vec![], false)); - last = p; - first = p; - continue; - } - PathEl::ClosePath => { - if let Some(s) = subs.last_mut() { - s.1 = true; - } - last = first; - continue; - } - PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), - PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), - PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), - }; - last = seg.end(); - if subs.is_empty() { - subs.push((vec![], false)); - } - if let Some(s) = subs.last_mut() { - s.0.push(seg); - } - } - if let Some((segs, false)) = subs.first_mut() - && start > 0.0 - { - let mut left = start; - let mut drop_count = 0; - for (i, seg) in segs.iter().enumerate() { - let l = seg.arclen(ACC); - if l > left { - let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0); - segs.drain(..i); - segs[0] = partial; - drop_count = 0; - break; - } - left -= l; - drop_count = i + 1; - } - if drop_count > 0 { - segs.drain(..drop_count); - } - } - if let Some((segs, false)) = subs.last_mut() - && end > 0.0 - { - let mut left = end; - while let Some(seg) = segs.last().copied() { - let l = seg.arclen(ACC); - if l > left { - if let Some(s1) = segs.last_mut() { - *s1 = seg.subsegment(0.0..seg.inv_arclen(l - left, ACC)); - } - break; - } - left -= l; - segs.pop(); - } - } - let mut out = BezPath::new(); - for (segs, closed) in subs { - let Some(s0) = segs.first() else { continue }; - out.move_to(s0.start()); - for seg in &segs { - match seg { - kurbo::PathSeg::Line(l) => out.line_to(l.p1), - kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), - kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), - } - } - if closed { - out.close_path(); - } - } - out -} - -#[allow(clippy::too_many_arguments)] -fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, m: Affine, lw: f64) { - let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return }; - let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return }; - // The outline's ends in the space the arrowheads are sized and drawn in. - let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2); - let col = match &line.fill { - Some(Fill::Solid { color: c }) => rctx.color(c, ph), - _ => Rgba::BLACK, - }; - // A gradient line's heads take the gradient where they are. - let grad = match &line.fill { - Some(Fill::Gradient(g)) if !g.stops.is_empty() => { - let mut moved = sp.path.clone(); - moved.apply_affine(rel); - Some(line_gradient(rctx, g, ph, &moved, lw, 1.0)) - } - _ => None, - }; - let set_paint = |ctx: &mut RenderContext| match &grad { - Some(g) => ctx.set_paint(g.clone()), - None => ctx.set_paint(color(col, 1.0)), - }; - for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] { - let Some(e) = end else { continue }; - if e.kind == "none" { - continue; - } - let (wl, ll) = head_size(e, lw); - let dir = (tip - from).normalize(); - if !dir.x.is_finite() { - continue; - } - let n = Vec2::new(-dir.y, dir.x); - let base = tip - dir * ll; - let mut p = BezPath::new(); - match e.kind.as_str() { - "oval" => { - let c = kurbo::Ellipse::new(tip, (ll / 2.0, wl / 2.0), dir.y.atan2(dir.x)); - p = c.to_path(0.05); - } - "diamond" => { - p.move_to(tip + dir * ll / 2.0); - p.line_to(tip + n * wl / 2.0); - p.line_to(tip - dir * ll / 2.0); - p.line_to(tip - n * wl / 2.0); - p.close_path(); - } - "stealth" => { - p.move_to(tip); - p.line_to(base + n * wl / 2.0); - p.line_to(tip - dir * ll * 0.6); - p.line_to(base - n * wl / 2.0); - p.close_path(); - } - "arrow" => { - // Legs the line's width with round ends, meeting in a sharp point whose outside is - // on the line's end. - let leg = lw.max(0.75); - let back = dir * open_arrow_setback(wl, ll, leg); - let mut o = BezPath::new(); - o.move_to(base + n * wl / 2.0 - back); - o.line_to(tip - back); - o.line_to(base - n * wl / 2.0 - back); - ctx.set_transform(m); - set_paint(ctx); - ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round)); - ctx.stroke_path(&o); - continue; - } - _ => { - p.move_to(tip); - p.line_to(base + n * wl / 2.0); - p.line_to(base - n * wl / 2.0); - p.close_path(); - } - } - ctx.set_transform(m); - set_paint(ctx); - ctx.fill_path(&p); - } -} - -/// The prompt shown in an empty placeholder, formatted like its content would be. -fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { - // Layout/master prompt text when the layout supplies a custom one. - let (lp, _) = resolve::parents(rctx, s); - let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty()); - if let Some(t) = custom { - return t.clone(); - } - if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) { - return TextBody::default(); - } - let txt = kind.prompt(); - if txt.is_empty() || kind.is_footer_kind() { - return TextBody::default(); - } - let mut t = TextBody::from_text(txt); - if matches!(kind, PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram | PhType::ClipArt) { - t.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); - if let Some(p) = t.paragraphs.first_mut() { - p.props.align = Some(deckcraft_model::text::Align::Center); - p.props.bullet = Some(deckcraft_model::text::Bullet::None); - p.props.margin_left = Some(0.0); - p.props.indent = Some(0.0); - for r in &mut p.runs { - r.props.size = Some(14.0); - } - } - } - t -} - -thread_local! { - static GLYPH_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); -} - -pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { - let key = (face.id(), gid); - if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { - return p; - } - let p = FontDb::global().outline(face, gid); - GLYPH_CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 200_000 { - c.clear(); - } - c.insert(key, p.clone()); - }); - p -} - -/// Draw a text body into shape space `m` within the text rect. -pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option) { - let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); - draw_layout(ctx, &l, tr, m); -} - -/// Add transient list markers to an editor layout, using the document's own bullet glyphs. -fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) { - for mut run in l.empty_bullets.drain(..) { - run.color = Rgba::rgb(0xB3, 0xB3, 0xB3); - run.alpha = 1.0; - l.runs.push(run); - } -} - -/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local → -/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre. -/// -/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's -/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about -/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180° -/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this. -pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { - let c = tr.center().to_vec2(); - let mut t = m; - if m.determinant() < 0.0 && c.is_finite() { - t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c); - } - if rotation != 0.0 && rotation.is_finite() && c.is_finite() { - t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c); - } - t -} - -/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws. -pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option { - let bp = resolve::body(rctx, s); - let name = bp.warp.as_deref()?; - deckcraft_geom::warp::warp(name, &bp.warp_adj, tr) -} - -/// Draw an already laid-out text block. -pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { - draw_layout_with(ctx, l, tr, m, &[]); -} - -/// Draw a laid-out text block with per-paragraph animation states. -pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { - let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); - let m = text_transform(m, tr, l.rotation); - let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { - let ps = para(d.para); - if !ps.visible || ps.opacity <= 0.001 { - return; - } - ctx.set_transform(m * Affine::translate(ps.offset)); - ctx.set_paint(color(d.color, ps.opacity)); - fill_deco(ctx, d); - }; - for d in l.decos.iter().filter(|d| d.behind) { - deco(ctx, d); - } - // Text shadows, then glows, under all the text. - for pass in [TextEffect::Shadow, TextEffect::Glow] { - for run in &l.runs { - let ps = para(run.para); - if ps.visible && ps.opacity > 0.001 { - text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity); - } - } - } - for run in &l.runs { - if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { - continue; - } - let ps = para(run.para); - if !ps.visible || ps.opacity <= 0.001 { - continue; - } - let m = m * Affine::translate(ps.offset); - // A gradient fill starts over on every line and spans that line's text (from its top to - // its bottom, from its first to its last character), as in PowerPoint. - let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new(); - for (gid, x, y) in &run.glyphs { - let at = m * glyph_place(run, *gid, *x, *y); - let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { - draw_glyph(ctx, run, *gid, at, ps.opacity); - continue; - }; - let li = line_at(l, run.para, *y); - if !grads.iter().any(|(i, _)| *i == li) { - let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; - let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); - let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr); - grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity))); - } - let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y)))); - draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill); - } - } - for d in l.decos.iter().filter(|d| !d.behind) { - deco(ctx, d); - } -} - -/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a -/// quarter turn back about its em box's centre when it stands upright in vertical text. -pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine { - let at = Affine::translate((x, y)); - match run.upright_centre(gid, x, y) { - Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at, - None => at, - } -} - -/// Fill an underline, strikethrough or highlight with the current paint and transform: its box, -/// its dashes, or its zigzag stroked. -pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { - if let Some((pts, w)) = d.wave() { - let mut path = BezPath::new(); - for (i, p) in pts.iter().enumerate() { - if i == 0 { - path.move_to(*p); - } else { - path.line_to(*p); - } - } - ctx.set_stroke(kurbo::Stroke::new(w.max(0.01))); - ctx.stroke_path(&path); - return; - } - for r in d.pieces() { - ctx.fill_rect(&r); - } -} - -/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). -fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { - if let Some(&(first, count)) = l.para_lines.get(para) { - if count == 0 { - return 0; - } - let end = (first + count).min(l.lines.len()); - if first < end { - return l.lines[first..end] - .iter() - .enumerate() - .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) - .map(|(i, _)| first + i) - .unwrap_or(0); - } - } - l.lines - .iter() - .enumerate() - .filter(|(_, li)| li.para == para) - .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) - .map(|(i, _)| i) - .unwrap_or(0) -} - -/// The box a line's text covers: its top and bottom, its first and last caret. -fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { - let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x))); - let r = Rect::new(x0, li.top, x1, li.bottom); - (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) -} - -#[derive(Clone, Copy, PartialEq)] -enum TextEffect { - Shadow, - Glow, -} - -/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. -fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { - let Some(fx) = run.effects else { return }; - let k = run.size / run.face.upem.max(1.0); - let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - let px = scale_of(m); - let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) { - (TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0), - // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r - // past the glyph's edge and fades out by about two thirds of r. - (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68), - _ => return, - }; - let alpha = (c.a as f64 / 255.0) * opacity; - if alpha <= 0.0 { - return; - } - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma))); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - for (gid, x, y) in &run.glyphs { - let path = glyph(&run.face, *gid); - if path.elements().is_empty() { - continue; - } - // The offset is in slide space: a shadow falls the same way whatever the text's rotation. - let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k); - ctx.set_transform(at); - ctx.fill_path(&path); - if grow > 0.0 { - ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - } - } - ctx.pop_layer(); -} - -/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. -pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { - draw_glyph_with(ctx, run, gid, at, opacity, None); -} - -/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from -/// the glyph's origin to that space) instead of the run's colour. -fn draw_glyph_with( - ctx: &mut RenderContext, - run: &deckcraft_text::GlyphRun, - gid: u32, - at: Affine, - opacity: f64, - fill: Option<(&peniko::Gradient, Affine)>, -) { - let alpha = run.alpha * opacity; - if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { - return; - } - let k = run.size / run.face.upem.max(1.0); - // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still - // strokes the plain glyph below. - if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { - return; - } - let path = glyph(&run.face, gid); - if path.elements().is_empty() { - return; - } - let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - ctx.set_transform(at * skew * Affine::scale(k)); - if let Some((g, to_text)) = fill - && alpha > 0.0 - { - ctx.set_paint(g.clone()); - ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text); - ctx.fill_path(&path); - ctx.reset_paint_transform(); - } else if alpha > 0.0 && run.color.a > 0 { - ctx.set_paint(color(run.color, alpha)); - ctx.fill_path(&path); - if run.fake_bold { - ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - } - } - if let Some((oc, ow)) = run.outline { - ctx.set_paint(color(oc, opacity)); - ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - } -} - -/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). -pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { - if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { - return None; - } - let rctx = Ctx::for_slide(pres, slide)?; - let mut items = Vec::new(); - let mut bounds: Option = None; - for id in ids.iter().take(256) { - let Some(s) = slide.shape(*id) else { continue }; - let b = resolve::xfrm(&rctx, s).bounds(); - if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { - continue; - } - bounds = Some(match bounds { - Some(u) => u.union(b), - None => b, - }); - items.push(s); - } - let b = bounds?.inflate(8.0, 8.0); - if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { - return None; - } - let fit = (2048.0 / b.width().max(b.height())).min(scale); - if !(fit.is_finite() && fit > 0.0) { - return None; - } - let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; - let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; - let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - let opts = RenderOpts { scale: fit, ..Default::default() }; - let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; - let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); - RENDERER.with(|r| { - let mut r = r.borrow_mut(); - for s in items { - r.shape(&mut ctx, &f, &rctx, s, view, 0); - } - Some(r.finish(ctx, w, h)) - }) -} - -/// Render one shape alone onto a transparent canvas (drag previews, copy as picture). -pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { - let rctx = Ctx::for_slide(pres, slide)?; - let s = slide.shape(id)?; - let x = resolve::xfrm(&rctx, s); - let b = x.bounds().inflate(8.0, 8.0); - let w = (b.width() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; - let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; - let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - let opts = RenderOpts { scale, ..Default::default() }; - let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; - let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2()); - RENDERER.with(|r| { - let mut r = r.borrow_mut(); - r.shape(&mut ctx, &f, &rctx, s, view, 0); - Some(r.finish(ctx, w, h)) - }) -} +mod geometry; +mod opts; +mod raster; +mod renderer; +mod slide; +mod stroke; +mod text_draw; + +use geometry::shade; +pub use geometry::{camera_turn, crop_dest, parse_path, shape_geometry}; +pub(crate) use geometry::{color, scale_of}; +use opts::SlideFields; +pub use opts::{ParaState, RenderOpts, ShapeState}; +pub use raster::{Image, MAX_SIDE, dpi_for_scale, image_dpi}; +#[cfg(test)] +use renderer::RENDERER; +pub use renderer::Renderer; +pub use slide::{ + SlidePart, needs_filters, panics_caught, render_blend, render_layout, render_shape, render_shapes, render_slide, render_slide_drawn, + render_slide_part, +}; +pub(crate) use stroke::stroke; +#[cfg(test)] +use stroke::{MAX_DASHES, dash_with_caps, head_size, limit_dash_count, trim_ends}; +use text_draw::preview_empty_bullets; +pub(crate) use text_draw::{draw_glyph, fill_deco, glyph, glyph_place}; +pub use text_draw::{draw_layout, draw_layout_with, draw_text, text_transform, text_warp}; #[cfg(test)] mod tests; diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs new file mode 100644 index 000000000..c6579b9bd --- /dev/null +++ b/crates/render/src/opts.rs @@ -0,0 +1,132 @@ +use super::*; + +/// Animation state of one shape during a slide show or preview. +#[derive(Clone, Debug)] +pub struct ShapeState { + pub visible: bool, + pub opacity: f64, + /// Applied in slide space around the shape's centre: translation (pt), scale, rotation (deg). + pub offset: Vec2, + pub scale: (f64, f64), + pub rotate: f64, + /// Reveal clip in shape-local unit space (0..1): l, t, r, b. + pub clip: Option<[f64; 4]>, + /// Colour override for emphasis effects (fill). + pub tint: Option, + /// Per-paragraph states of by-paragraph text builds (paragraph index, state). + pub paras: Vec<(usize, ParaState)>, +} + +impl Default for ShapeState { + fn default() -> Self { + ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] } + } +} + +/// Animation state of one paragraph, relative to its shape. +#[derive(Clone, Copy, Debug)] +pub struct ParaState { + pub visible: bool, + pub opacity: f64, + /// Translation in shape-local points. + pub offset: Vec2, +} + +impl Default for ParaState { + fn default() -> Self { + ParaState { visible: true, opacity: 1.0, offset: Vec2::ZERO } + } +} + +pub struct RenderOpts<'a> { + /// Pixels per point. + pub scale: f64, + /// Editor view: placeholder prompts and dashed placeholder outlines. + pub edit: bool, + /// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does. + pub show_hidden: bool, + /// Per-shape animation state. + pub state: Option<&'a dyn Fn(ShapeId) -> ShapeState>, + /// Don't draw these shapes (being edited in place, drawn by the UI). + pub skip: &'a [ShapeId], + /// Hide the text of this shape (the UI draws it while editing). + pub skip_text: Option, + /// Text being edited: replace its prompt with gray markers on empty list paragraphs. + /// Ignored outside editor rendering. + pub editing_text: Option, + /// Animated GIF pictures and how far into their play they are (seconds); others show frame 1. + pub gif_times: &'a [(ShapeId, f64)], + /// Worker threads (0 = this thread; required when effects use filters). + pub threads: u16, + /// Translate the slide within the output (pixels) and output size override. + pub offset: (f64, f64), + pub size: Option<(u32, u32)>, + /// Draw a background colour outside the slide (output larger than slide). + pub clear: Option, + /// Show this text in every date field instead of the date. + pub date_text: Option, + /// The local date and time date fields show (`None`: the system clock). + pub now: Option, + /// Leave the slide background out (neither the white underlay nor the background fill), so + /// only what is on the slide is drawn over transparency: PNG export with a transparent + /// background. Shapes filled with the background (`useBgFill`) still show it. + pub transparent_background: bool, +} + +impl Default for RenderOpts<'_> { + fn default() -> Self { + RenderOpts { + scale: 1.0, + edit: false, + show_hidden: false, + state: None, + skip: &[], + skip_text: None, + editing_text: None, + gif_times: &[], + threads: 0, + offset: (0.0, 0.0), + size: None, + clear: None, + date_text: None, + now: None, + transparent_background: false, + } + } +} + +pub(crate) struct SlideFields { + pub(crate) num: u32, + /// Text for every date field instead of the date ([`RenderOpts::date_text`]). + pub(crate) date: Option, + pub(crate) footer: String, + /// The local date and time date fields show; `None`: they keep their saved text. + pub(crate) now: Option, +} + +impl SlideFields { + pub(crate) fn new(num: u32, footer: String, opts: &RenderOpts) -> Self { + SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) } + } +} + +impl Fields for SlideFields { + fn field(&self, kind: &str) -> Option { + self.field_in(kind, None) + } + + fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { + if kind == "slidenum" { + return Some(self.num.to_string()); + } + if datetime::is_date_field(kind) { + // An automatic date: today's, in the format of its type. (A fixed date is plain text + // in the slide's placeholder, not a field.) + return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?)); + } + if kind == "footer" { + return Some(self.footer.clone()); + } + None + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs new file mode 100644 index 000000000..19ba78255 --- /dev/null +++ b/crates/render/src/raster.rs @@ -0,0 +1,113 @@ +use super::*; + +/// Largest pixmap side we render (vello_cpu uses u16 sizes). +pub const MAX_SIDE: u32 = 16_000; + +/// A rendered image: premultiplied RGBA8. +#[derive(Clone, Debug, Default)] +pub struct Image { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Image { + /// Straight (unpremultiplied) RGBA. + pub fn to_straight(&self) -> Vec { + let mut out = self.pixels.clone(); + for px in out.as_chunks_mut::<4>().0 { + let a = px.get(3).copied().unwrap_or(255) as u32; + if a != 0 && a != 255 { + for c in px.iter_mut().take(3) { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + out + } + /// One pixel as straight (unpremultiplied) RGBA; `None` outside the image. + pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> { + if x >= self.width || y >= self.height { + return None; + } + let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?; + let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?; + if a == 0 || a == 255 { + return Some([r, g, b, a]); + } + let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8; + Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a]) + } + pub fn to_png(&self) -> Vec { + let mut px = self.to_straight(); + px.resize(self.width as usize * self.height as usize * 4, 0); + let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { return Vec::new() }; + let mut buf = Vec::new(); + if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) { + log::error!("PNG encode: {e}"); + return Vec::new(); + } + buf + } + + /// PNG of this image whose `pHYs` chunk says `dpi` dots per inch. + /// + /// Slide space is points, 72 per inch, so a render at `scale` pixels per point is + /// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a + /// Retina (2×) export opens at twice the slide's size. + pub fn to_png_at(&self, dpi: f64) -> Vec { + images::stamp_png_dpi(&self.to_png(), dpi) + } + + pub fn to_jpeg(&self, quality: u8) -> Vec { + self.to_jpeg_at(quality, None) + } + + /// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range. + pub fn to_jpeg_at(&self, quality: u8, dpi: Option) -> Vec { + let straight = self.to_straight(); + let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; + let mut buf = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + if let Some(d) = dpi.and_then(dpi_u16) { + enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d)); + } + if let Err(e) = img.write_with_encoder(enc) { + log::error!("JPEG encode: {e}"); + return Vec::new(); + } + buf + } + /// Pixel at (x, y) as straight RGBA. + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = (y as usize * self.width as usize + x as usize) * 4; + let p = self.pixels.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]); + let a = p.get(3).copied().unwrap_or(0) as u32; + let un = |c: u8| (c as u32 * 255 + a / 2).checked_div(a).map(|v| v.min(255) as u8).unwrap_or(0); + [un(p[0]), un(p[1]), un(p[2]), a as u8] + } +} + +/// Dots per inch for a slide drawn at `pixels_per_point`. +/// +/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi: +/// the same pixels, at the slide's real size, on a Retina display. +pub fn dpi_for_scale(pixels_per_point: f64) -> f64 { + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return 72.0; + } + (pixels_per_point * 72.0).clamp(1.0, 10_000.0) +} + +fn dpi_u16(dpi: f64) -> Option { + if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) { + return None; + } + Some(dpi.round() as u16) +} + +/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch. +pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> { + images::dpi(bytes) +} diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs new file mode 100644 index 000000000..510cb6c57 --- /dev/null +++ b/crates/render/src/renderer.rs @@ -0,0 +1,1024 @@ +use super::geometry::{Caster, blur, border_outside, camera_turn, color, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry}; +use super::opts::{RenderOpts, ShapeState, SlideFields}; +use super::raster::Image; +use super::slide::{SlidePart, needs_filters, output_size}; +use super::stroke::{arrowheads, head_trims, stroke, trim_ends}; +use super::text_draw::{draw_layout_with, draw_text, line_bounds, preview_empty_bullets, prompt_body, text_transform, text_warp}; +use super::*; + +pub struct Renderer { + resources: Resources, + /// Outlines morphing in the frame being drawn by `blend`. + paths: Vec, + /// 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. + pub(crate) drawn: Option, + /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw + /// under it combined with the group's child-coordinate mapping, which must not scale lines. + view: Affine, + /// The fill of the group being drawn, with the transform and box it is laid out in: children + /// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group. + group_fill: Option<(Fill, Affine, Rect)>, + /// Their aligned outlines, kept from frame to frame. + morphs: morph_path::Cache, + /// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group. + in_shadow_pass: bool, + /// Cumulative shape traversal counter to prevent nested group amplification DoS. + render_work: usize, + /// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget + /// of the normal drawing that follows. + shadow_work: usize, +} + +impl Default for Renderer { + fn default() -> Self { + Renderer { + resources: Resources::new(), + paths: Vec::new(), + bg: None, + drawn: None, + view: Affine::IDENTITY, + group_fill: None, + morphs: morph_path::Cache::default(), + in_shadow_pass: false, + render_work: 0, + shadow_work: 0, + } + } +} + +thread_local! { + pub(crate) static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); +} + +pub(crate) struct Frame<'a> { + pub(crate) pres: &'a Presentation, + pub(crate) opts: &'a RenderOpts<'a>, + pub(crate) fields: SlideFields, +} + +impl Renderer { + fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { + let (w, h) = output_size(pres, opts); + let mut ctx = RenderContext::new_with( + w, + h, + vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() }, + ); + if let Some(c) = opts.clear { + ctx.set_paint(color(c, 1.0)); + ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + } + let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); + (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))); + } + } + + pub(crate) fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields { + SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts) + } + + /// Background plus the master and layout graphics the slide shows. + fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { + self.background(ctx, f, rctx, Some(slide), view); + let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); + if show_master { + let mctx = Ctx::for_master(f.pres, rctx.master); + for s in &rctx.master.shapes { + if s.ph.is_none() { + self.shape(ctx, f, &mctx, s, view, 0); + } + } + } + if show_layout && let Some(l) = rctx.layout { + let lctx = Ctx::for_layout(f.pres, rctx.master, l); + for s in &l.shapes { + if s.ph.is_none() { + self.shape(ctx, f, &lctx, s, view, 0); + } + } + } + } + + pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); + let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; + if let Some(rctx) = Ctx::for_slide(pres, slide) { + self.backdrop(&mut ctx, &f, &rctx, slide, view); + for s in &slide.shapes { + self.shape(&mut ctx, &f, &rctx, s, view, 0); + } + } + self.finish(ctx, w, h) + } + + pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); + let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; + if let Some(rctx) = Ctx::for_slide(pres, slide) { + match part { + SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view), + SlidePart::Content => { + // Shapes filled with the background (`useBgFill`) still show it. + let (fill, ph) = resolve::background(&rctx, Some(slide)); + self.bg = Some((fill, ph, view)); + for s in &slide.shapes { + self.shape(&mut ctx, &f, &rctx, s, view, 0); + } + } + } + } + self.finish(ctx, w, h) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn blend( + &mut self, + pres: &Presentation, + old: (&Slide, usize), + new: (&Slide, usize), + mix: f64, + shapes: &[(Shape, bool)], + text: &[TextMorph], + paths: &[PathMorph], + opts: &RenderOpts, + ) -> Image { + let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); + let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; + // Backdrop graphics never take shape states: their ids may clash with the frame's shapes. + let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts }; + if mix < 1.0 { + let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) }; + self.backdrop(&mut ctx, &f, &co, old.0, view); + } + if mix > 0.0 { + let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) }; + let layered = mix < 1.0; + if layered { + ctx.push_layer(None, None, Some(mix as f32), None, None); + } + self.backdrop(&mut ctx, &f, &cn, new.0, view); + if layered { + ctx.pop_layer(); + } + } + let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) }; + let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) }; + let mut drawn: Vec = Vec::new(); + // Set only now: the backdrop's ids may clash with the frame's. + self.paths = paths.to_vec(); + for (s, from_old) in shapes { + let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) }; + let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else { + self.shape(&mut ctx, f, c, s, view, 0); + continue; + }; + self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0); + drawn.push(s.id); + if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) { + continue; + } + let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id); + if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) { + let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b)); + morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view); + } + } + self.paths.clear(); + self.morphs.sweep(); + self.finish(ctx, w, h) + } + + pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (w, h) = output_size(pres, opts); + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); + let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) }; + if let Some(m) = pres.masters.get(mi) { + match li.and_then(|i| m.layouts.get(i)) { + Some(l) => { + let c = Ctx::for_layout(pres, m, l); + self.background(&mut ctx, &f, &c, None, view); + if l.show_master_shapes { + let mc = Ctx::for_master(pres, m); + for s in m.shapes.iter().filter(|s| s.ph.is_none()) { + self.shape(&mut ctx, &f, &mc, s, view, 0); + } + } + for s in &l.shapes { + self.shape(&mut ctx, &f, &c, s, view, 0); + } + } + None => { + let c = Ctx::for_master(pres, m); + self.background(&mut ctx, &f, &c, None, view); + for s in &m.shapes { + self.shape(&mut ctx, &f, &c, s, view, 0); + } + } + } + } + self.finish(ctx, w, h) + } + + pub(crate) fn finish(&mut self, mut ctx: RenderContext, w: u16, h: u16) -> Image { + ctx.flush(); + let mut pixels = vec![0u8; w as usize * h as usize * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) { + ctx.render(pm, &mut self.resources); + } + Image { width: w as u32, height: h as u32, pixels } + } + + fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { + let (fill, ph) = resolve::background(rctx, slide); + if f.opts.transparent_background { + self.bg = Some((fill, ph, view)); + return; + } + let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); + ctx.set_transform(view); + let path = r.to_path(0.1); + // PowerPoint lays every background over white: a picture stretched into part of the + // slide, or a see-through colour, leaves white around and under it. + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&path); + paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); + self.bg = Some((fill, ph, view)); + } + + pub(crate) fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + if depth == 0 { + let outer = std::mem::replace(&mut self.view, parent); + self.shape_at(ctx, f, rctx, s, parent, depth); + self.view = outer; + } else { + self.shape_at(ctx, f, rctx, s, parent, depth); + } + } + + /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the + /// one that takes the shape's outline into it. Inside a group they are the slide-level view and + /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but + /// never the line width; outside a group, `m` itself and no change to the outline. + fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) { + let view = self.view; + if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } + } + + /// Shape-space units per point: a group's child coordinates can scale a shape's box (a legacy + /// 1360-unit child space makes one unit hundreds of EMUs), but 3-D sizes kept in points + /// (contour width, extrusion depth, bevel) stay points, as PowerPoint draws them. 1 outside a + /// group. + fn per_point(&self, m: Affine, depth: usize) -> f64 { + let k = scale_of(self.line_space(m, depth).1); + if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } + } + + /// The size and transform a shape's text is set in: inside a group whose child coordinates + /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so + /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. + fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) { + let (_, rel) = self.line_space(m, depth); + let c = rel.as_coeffs(); + let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3])); + let ok = |k: f64| k.is_finite() && k > 1e-9; + if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) { + return (w, h, m); + } + let (tw, th) = (w * sx, h * sy); + if !tw.is_finite() || !th.is_finite() { + return (w, h, m); + } + (tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy)) + } + + /// A shape's geometry in its own `w` × `h` space, built at its size on the slide: inside a + /// group whose child coordinates scale the shape unevenly, the preset's rounded corners and + /// other size-derived features stay as they would be on a plain shape of that size (#1083). + fn scaled_geometry(&self, s: &Shape, m: Affine, depth: usize, w: f64, h: f64) -> Geometry { + let (tw, th, _) = self.text_space(m, depth, w, h); + if (tw, th) == (w, h) { + return shape_geometry(s, w, h); + } + let mut geo = shape_geometry(s, tw, th); + let k = |a: f64, b: f64| if b.abs() > 1e-12 { a / b } else { 1.0 }; + let back = Affine::scale_non_uniform(k(w, tw), k(h, th)); + for p in &mut geo.paths { + p.path.apply_affine(back); + } + geo.text_rect = back.transform_rect_bbox(geo.text_rect); + for p in &mut geo.sites { + *p = back * *p; + } + geo + } + + fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { + return; + } + let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work }; + *work = work.saturating_add(1); + if *work > 100_000 { + return; + } + // Footer placeholders on layouts/masters show only on slides that include them. + let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default(); + if !st.visible || st.opacity <= 0.001 { + return; + } + let x = resolve::xfrm(rctx, s); + if !x.is_finite() { + return; + } + let mut m = parent * x.affine(); + if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 { + let c = Point::new(x.w / 2.0, x.h / 2.0); + let around = Affine::translate(c.to_vec2()) + * Affine::rotate(st.rotate.to_radians()) + * Affine::scale_non_uniform(st.scale.0, st.scale.1) + * Affine::translate(-c.to_vec2()); + m = parent * Affine::translate(st.offset) * x.affine() * around; + } + if let Some(a) = camera_turn(rctx, s, x.w, x.h) { + m *= a; + } + let layered = st.opacity < 0.999 || st.clip.is_some(); + if layered { + ctx.set_transform(m); + let clip = st.clip.map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1)); + ctx.push_layer(clip.as_ref(), None, Some(st.opacity.clamp(0.0, 1.0) as f32), None, None); + } + match &s.kind { + ShapeKind::Group { children, child } => { + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let gm = parent * deckcraft_geom::group_child_affine(&x, ch); + // A group's own fill (a group filled with its parent's passes that on). + let own = match &s.fill { + Some(Fill::Group) | None => self.group_fill.clone(), + Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), + }; + let outer = std::mem::replace(&mut self.group_fill, own); + // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the + // children drawn once more under the shadow's offset, scale and skew, recoloured to + // the shadow colour and blurred, below the group. + if !self.in_shadow_pass + && let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) + { + let c = rctx.color(&sh.color, None); + let k = scale_of(m); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + let local = shadow_local(sh, x.w, x.h); + let (own, pre) = + if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; + let det = m.determinant(); + if det.is_finite() && det.abs() > 1e-12 { + let shift = pre * m * own * m.inverse(); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); + self.in_shadow_pass = true; + for ch in children { + self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); + } + self.in_shadow_pass = false; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); + ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); + ctx.pop_layer(); + ctx.pop_layer(); + } + } + for c in children { + self.shape(ctx, f, rctx, c, gm, depth + 1); + } + self.group_fill = outer; + } + 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, f.opts.edit && f.opts.editing_text == Some(s.id)); + 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 legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); + let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, pr) in &sk.points { + let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); + self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); + } + let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); + if pts.len() < 2 { + continue; + } + let (base, rel) = self.line_space(parent, depth); + ctx.set_transform(base); + ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); + // Uniform-width and highlighter strokes are one path: segments drawn one by one + // overlap at the joins, which darkens see-through highlighter ink in beads. + if legacy || sk.highlighter { + let mut bp = BezPath::new(); + for (i, (px, py, _)) in pts.iter().enumerate() { + if i == 0 { + bp.move_to((*px, *py)); + } else { + bp.line_to((*px, *py)); + } + } + bp.apply_affine(rel); + ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + continue; + } + for pair in pts.windows(2) { + let [p0, p1] = pair else { continue }; + let mut bp = BezPath::new(); + bp.move_to((p0.0, p0.1)); + bp.line_to((p1.0, p1.1)); + bp.apply_affine(rel); + let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy); + let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy); + ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } + } + } + _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), + } + if layered { + ctx.pop_layer(); + } + } + + /// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per + /// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill` + /// the group's fill. + #[allow(clippy::too_many_arguments)] + fn fill_geometry( + &self, + ctx: &mut RenderContext, + f: &Frame, + rctx: &Ctx, + fl: &Fill, + fill_ph: Option, + geo: &Geometry, + (w, h): (f64, f64), + m: Affine, + fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, + ) { + for (i, sp) in geo.paths.iter().enumerate() { + if sp.fill == FillMode::None || fade(i).fill <= 0.0 { + continue; + } + ctx.set_transform(m); + ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + match (fl, &self.bg) { + // `useBgFill`: the slide's background, laid out on the slide (not the + // shape), shows through the shape. + (Fill::Background, Some((bg, ph, view))) => { + let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); + let path = (view.inverse() * m) * sp.path.clone(); + paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres); + } + // `grpFill`: the group's fill, laid out over the whole group. + (Fill::Group, _) => { + if let Some((gf, gm, gbox)) = &self.group_fill + && gm.determinant().abs() > 1e-12 + { + let path = (gm.inverse() * m) * sp.path.clone(); + paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); + } + } + _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), + } + ctx.set_fill_rule(peniko::Fill::NonZero); + } + } + + #[allow(clippy::too_many_arguments)] + fn geometry_shape( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + rctx: &Ctx, + s: &Shape, + m: Affine, + w: f64, + h: f64, + st: &ShapeState, + depth: usize, + ) { + let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { + Some(pm) => self.morphs.geometry(pm, s, w, h), + None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), + }; + let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); + 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. + let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); + let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; + // Effects below the shape. + if let Some(e) = &effects { + // A picture (or picture fill) casts its shadow from its pixels, not its frame. + let content = match (&s.kind, &fill) { + (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), + _ => None, + }; + // What casts the shadow: the fill as see-through as it is, and the outline. + let fill_alpha = fill_opacity(rctx, &fill, fill_ph); + let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { + let a = match lf { + Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, + _ => 1.0, + }; + let (base, rel) = self.line_space(m, depth); + let lw = line.width.unwrap_or(0.75).max(0.0); + let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } + }); + self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); + } + let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); + if let Some(r) = soft { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + let _ = r; + } + // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which + // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. + let pt = { + let turn = effects + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, w, h)) + .filter(|t| t.determinant().abs() > 1e-9); + self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) + }; + // A 3-D extrusion under a turned camera: the sides, swept behind the face. + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(ex) = extrude::from_raw(raw) + && let Some(cam) = camera::from_raw(raw) + && let Some(turn) = camera::affine(&cam, w, h) + && turn.determinant().abs() > 1e-9 + { + let face = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => None, + }; + let pre = m * turn.inverse(); + let lit = light::Lighting::from_raw(raw); + let d = ex.depth * pt; + extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); + } + // A 3-D contour: a band around the outline, behind the face (and in front of any sides). + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(c) = extrude::contour_from_raw(raw) + { + let face = match &fill { + Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)), + _ => None, + }; + let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); + extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); + } + // Fill. + match &s.kind { + ShapeKind::Picture { fill: pf } => { + // The picture's own fill shows behind its transparent pixels, as in PowerPoint (an + // emphasis tint recolours it, as it does any shape's fill). + if s.fill.as_ref().is_some_and(|f| !f.is_none()) + && let Some(fl) = &fill + { + self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + } + ctx.set_transform(m); + let bounds = Rect::new(0.0, 0.0, w, h); + ctx.push_clip_layer(&outline); + let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); + paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); + ctx.pop_layer(); + } + // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its + // preview picture, stretched over the frame, as PowerPoint draws it. + ShapeKind::Opaque { preview: Some(media), .. } => { + // An embedded object's fallback picture can carry a fill (old equation objects + // drawn on coloured boxes): behind the preview, as for a picture. + if s.fill.as_ref().is_some_and(|f| !f.is_none()) + && let Some(fl) = &fill + { + self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + } + ctx.set_transform(m); + let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; + paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None); + } + ShapeKind::Media(mc) => { + ctx.set_transform(m); + let bounds = Rect::new(0.0, 0.0, w, h); + if let Some(p) = mc.poster { + let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; + ctx.push_clip_layer(&outline); + paint::picture(ctx, f.pres, &pf, bounds, m, None); + ctx.pop_layer(); + } else if mc.video { + ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255)); + ctx.fill_rect(&bounds); + } + if !mc.video || mc.poster.is_none() { + paint::media_glyph(ctx, mc.video, bounds); + } + } + _ => { + if let Some(fl) = &fill + && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) + { + self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + } + } + } + // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply + // a gradient over a tiled picture), inside the filled outline, under the line. + if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) + && fill.as_ref().is_some_and(|f| !f.is_none()) + { + let mix = match ov.blend.as_str() { + "mult" => peniko::Mix::Multiply, + "screen" => peniko::Mix::Screen, + "darken" => peniko::Mix::Darken, + "lighten" => peniko::Mix::Lighten, + _ => peniko::Mix::Normal, + }; + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) { + ctx.set_transform(m); + ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); + paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); + ctx.pop_layer(); + } + } + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; + // A 3-D bevel: the face and a band inside the outline lit as surfaces. + if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { + let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; + let solid = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => None, + }; + let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none()); + if filled { + let mut area = BezPath::new(); + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { + area.extend(sp.path.iter()); + } + if is_pic || area.elements().is_empty() { + area = outline.clone(); + } + bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); + } + } + // Outline. + if line.fill.as_ref().is_some_and(|f| !f.is_none()) { + let lw = line.width.unwrap_or(0.75).max(0.0); + let (base, rel) = self.line_space(m, depth); + // Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed + // outlines or on presets like brackets. + let ends = s.is_line() + || geo.is_open() + || (matches!(s.geom, Geom::Custom { .. }) + && geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)))); + let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) }; + let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none")); + // A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into + // a layer that is then faded, so they don't darken where they meet. + let see_through = match &line.fill { + Some(Fill::Solid { color: c }) if has_heads => Some(rctx.color(c, line_ph)).filter(|c| c.a < 255), + _ => None, + }; + let line = match see_through { + Some(c) => { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None); + std::borrow::Cow::Owned(Line { + fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), + ..line.clone() + }) + } + None => std::borrow::Cow::Borrowed(&line), + }; + // A picture's border lies outside the picture, as PowerPoint draws it: stroked twice + // as wide, outside the outline only (see `border_outside`). + let outside = border_outside(is_pic, effects.as_ref()).then(|| outside_line(&line, lw)).flatten(); + let clipped = outside.is_some(); + let (line, lw) = match outside { + Some(o) => { + let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1); + clip.extend(outline.iter()); + ctx.set_transform(m); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.push_clip_layer(&clip); + ctx.set_fill_rule(peniko::Fill::NonZero); + (std::borrow::Cow::Owned(o), lw * 2.0) + } + None => (line, lw), + }; + let first_stroked = geo.paths.iter().position(|p| p.stroke); + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { + let mut path = if rel == Affine::IDENTITY { + std::borrow::Cow::Borrowed(&sp.path) + } else { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + std::borrow::Cow::Owned(moved) + }; + // The line stops inside its arrowheads, so a wide line doesn't poke out of them. + let whole = path.clone(); + if Some(i) == first_stroked && trims != (0.0, 0.0) { + path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1)); + } + stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); + } + if ends { + arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); + } + if clipped { + ctx.pop_layer(); + } + if see_through.is_some() { + ctx.pop_layer(); + } + } + if let Some(r) = soft { + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. + ctx.set_transform(m); + ctx.push_layer( + None, + Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), + None, + None, + Some(blur(r / 3.0 * scale_of(m))), + ); + ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + ctx.fill_path(&outline); + ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + ctx.stroke_path(&outline); + ctx.pop_layer(); + ctx.pop_layer(); + ctx.pop_layer(); + } + // Inner shadow. + if let Some(e) = &effects + && let Some(sh) = &e.inner_shadow + { + ctx.set_transform(m); + ctx.push_clip_layer(&outline); + let c = rctx.color(&sh.color, fx_ph); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + // As PowerPoint draws it (measured on its export): the shadow falls inside along the + // edges it points to (45 degrees: the bottom and right), blurred by a third of the + // blur radius. + ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m)))); + ctx.set_paint(color(c, 1.0)); + let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1); + let mut hole = outline.clone(); + hole.apply_affine(Affine::translate(-d)); + ring.extend(hole.iter()); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.fill_path(&ring); + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.pop_layer(); + ctx.pop_layer(); + } + // Text (or the placeholder prompt in the editor). + let skip_text = f.opts.skip_text == Some(s.id); + if let Some(body) = &s.text + && !skip_text + { + // Text in a group is set at the shape's size on the slide, in points: a group's + // child-coordinate scale sizes and places the box, never the type (#233's twin). + let (tw, th, m) = self.text_space(m, depth, w, h); + let text_geo; + let geo = if (tw, th) == (w, h) { + &geo + } else { + text_geo = shape_geometry(s, tw, th); + &text_geo + }; + let tr = geo.text_rect; + let editing = f.opts.edit && f.opts.editing_text == Some(s.id); + if !body.is_empty() || editing { + // Imported placeholders can have no paragraph until the first keystroke. + let mut empty; + let body = if editing && body.paragraphs.is_empty() { + empty = body.clone(); + empty.paragraphs.push(Default::default()); + &empty + } else { + body + }; + let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); + if editing { + preview_empty_bullets(&mut l); + } + 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), + } + } else if f.opts.edit && s.ph.is_some() { + let kind = s.ph_type().unwrap_or(PhType::Body); + let prompt_body = prompt_body(rctx, s, kind); + if !prompt_body.is_empty() { + let grey = Rgba::rgb(0x59, 0x59, 0x59); + draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); + } + } + } + if empty_ph && f.opts.edit { + // Dashed placeholder outline. + ctx.set_transform(m); + let px = 1.0 / scale_of(m).max(1e-6); + ctx.set_paint(peniko::Color::from_rgba8(0xA6, 0xA6, 0xA6, 255)); + ctx.set_stroke(kurbo::Stroke::new(px).with_dashes(0.0, [3.0 * px, 2.0 * px])); + ctx.stroke_path(&Rect::new(0.0, 0.0, w, h).to_path(0.1)); + if matches!(s.ph_type(), Some(PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram)) { + paint::placeholder_icon(ctx, s.ph_type().unwrap_or(PhType::Picture), Rect::new(0.0, 0.0, w, h)); + } + } + } + + fn effects_below( + &mut self, + ctx: &mut RenderContext, + rctx: &Ctx, + e: &Effects, + ph: Option, + geo: &Geometry, + fill: &Option, + fill_alpha: Option, + outline: Option, + content: Option<&deckcraft_model::style::PictureFill>, + m: Affine, + w: f64, + h: f64, + ) { + let silhouette = { + let mut p = BezPath::new(); + for sp in &geo.paths { + if sp.fill != FillMode::None || fill.is_none() { + p.extend(sp.path.iter()); + } + } + if p.elements().is_empty() { geo.outline() } else { p } + }; + let k = scale_of(m); + if let Some(sh) = &e.outer_shadow { + let c = rctx.color(&sh.color, ph); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + let local = shadow_local(sh, w, h); + // Shadow offset is in slide space (doesn't rotate with the shape unless asked). + let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; + let sm = pre * m * own; + ctx.set_transform(Affine::IDENTITY); + // Blurred by a third of the blur radius, as PowerPoint's export shows. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); + ctx.set_transform(sm); + match content { + // As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a + // transparent background casts the logo's shadow): the picture, clipped to the + // shape, recoloured to the shadow colour. + Some(pf) => { + ctx.push_clip_layer(&silhouette); + paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None); + // Drawing the picture leaves its own pixel transform set: back to the shape's, + // or the recolouring below covers only part of a large picture's shadow. + ctx.set_transform(sm); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0)); + ctx.pop_layer(); + ctx.pop_layer(); + } + // The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as + // see-through as its fill (none for an outline-only shape) … + None => { + if let Some(a) = fill_alpha { + let mut filled = BezPath::new(); + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { + filled.extend(sp.path.iter()); + } + ctx.set_paint(color(c.with_alpha(255), a)); + ctx.fill_path(&filled); + } + } + } + // … and the outline (a picture's border too). + if let Some(o) = &outline { + let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() }; + let lw = o.line.width.unwrap_or(0.75).max(0.0); + for sp in geo.paths.iter().filter(|sp| sp.stroke) { + let mut path = sp.path.clone(); + path.apply_affine(o.rel * own); + stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha); + } + } + ctx.pop_layer(); + } + // A shape with no fill and no line (a text box) draws nothing to glow around: its text's + // own glow is all PowerPoint shows, not a halo round the box. + let glows = fill_alpha.is_some() || content.is_some() || outline.is_some(); + if let Some(g) = e.glow.as_ref().filter(|_| glows) { + let c = rctx.color(&g.color, ph); + ctx.set_transform(Affine::IDENTITY); + // Measured on PowerPoint's export: a glow of radius r is half strength about half of r + // out from the outline and gone by r. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k))); + ctx.set_transform(m); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round)); + ctx.stroke_path(&silhouette); + // A shape without a fill (an outline, a frame) glows along its line only (#225). + if fill_alpha.is_some() || content.is_some() { + ctx.fill_path(&silhouette); + } + ctx.pop_layer(); + } + if let Some(rf) = &e.reflection { + // Mirror below the shape, fading out. + let mm = m * Affine::translate((0.0, 2.0 * h + rf.dist)) * Affine::scale_non_uniform(1.0, -1.0); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6), None, Some(blur(rf.blur * 0.5 * k))); + ctx.set_transform(mm); + match content { + // A picture reflects its pixels (it has no fill of its own). + Some(pf) => { + ctx.push_clip_layer(&silhouette); + paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), mm, None); + ctx.pop_layer(); + } + None => { + if let Some(fl) = fill { + paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); + } + } + } + ctx.set_transform(mm); + // Fade mask. + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None); + let grad = peniko::Gradient::new_linear(Point::new(0.0, h), Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0)))) + .with_stops([(0.0, peniko::Color::from_rgba8(0, 0, 0, 255)), (1.0, peniko::Color::from_rgba8(0, 0, 0, 0))].as_slice()); + ctx.set_paint(grad); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(4.0, 4.0)); + ctx.pop_layer(); + ctx.pop_layer(); + } + } +} diff --git a/crates/render/src/slide.rs b/crates/render/src/slide.rs new file mode 100644 index 000000000..77040d97d --- /dev/null +++ b/crates/render/src/slide.rs @@ -0,0 +1,230 @@ +use super::opts::{RenderOpts, SlideFields}; +use super::raster::{Image, MAX_SIDE}; +use super::renderer::{Frame, RENDERER, Renderer}; +use super::*; + +static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn note_panic() { + PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. +pub fn panics_caught() -> u64 { + PANICS.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Does anything on this slide (or its layout/master graphics) use blur-based effects? +pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { + let Some(rctx) = Ctx::for_slide(pres, slide) else { return false }; + let mut any = false; + let mut check = |shapes: &[Shape]| { + deckcraft_model::walk(shapes, &mut |s, _| { + if any { + return; + } + let (e, _) = resolve::effects(&rctx, s); + if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) { + any = true; + } + }); + }; + check(&slide.shapes); + if let Some(l) = rctx.layout { + check(&l.shapes); + } + check(&rctx.master.shapes); + any +} + +/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's +/// text styles it inherits)? Such text is blurred, which needs filters. +fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { + use deckcraft_model::text::{ListStyle, RunProps}; + let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0); + let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run)); + let Some(t) = &s.text else { return false }; + t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props)) + || styled(&t.list_style) + || styled(&rctx.master.title_style) + || styled(&rctx.master.body_style) + || styled(&rctx.master.other_style) +} + +/// 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 { + 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"); + note_panic(); + 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] }, page) + } + } +} + +/// Which part of a slide [`render_slide_part`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SlidePart { + /// The background fill with the master and layout graphics. + Backdrop, + /// The slide's own shapes over transparency. + Content, +} + +/// Render one part of slide `index` (transitions that move the contents over the backgrounds). +/// A panic inside the renderer becomes a transparent image (logged), never a crash. +pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, 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_part(pres, slide, index, part, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering part of slide {index} panicked; showing nothing"); + note_panic(); + 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![0; w as usize * h as usize * 4] } + } + } +} + +/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the +/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by +/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`: +/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or +/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw +/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies +/// to `shapes` only. +pub fn render_blend( + pres: &Presentation, + old: usize, + new: usize, + mix: f64, + shapes: &[(Shape, bool)], + text: &[TextMorph], + paths: &[PathMorph], + opts: &RenderOpts, +) -> Image { + let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts)) + })) { + Ok(img) => img, + Err(_) => { + log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); + note_panic(); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + render_slide(pres, new, opts) + } + } +} + +/// Render a layout (Slide Master view thumbnail and canvas). +pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering a layout panicked; showing a blank image"); + note_panic(); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + Image::default() + } + } +} + +pub(crate) fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) { + let (w, h) = opts + .size + .unwrap_or(((pres.slide_size.width * opts.scale).round().max(1.0) as u32, (pres.slide_size.height * opts.scale).round().max(1.0) as u32)); + (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16) +} + +/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). +pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { + if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { + return None; + } + let rctx = Ctx::for_slide(pres, slide)?; + let mut items = Vec::new(); + let mut bounds: Option = None; + for id in ids.iter().take(256) { + let Some(s) = slide.shape(*id) else { continue }; + let b = resolve::xfrm(&rctx, s).bounds(); + if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { + continue; + } + bounds = Some(match bounds { + Some(u) => u.union(b), + None => b, + }); + items.push(s); + } + let b = bounds?.inflate(8.0, 8.0); + if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { + return None; + } + let fit = (2048.0 / b.width().max(b.height())).min(scale); + if !(fit.is_finite() && fit > 0.0) { + return None; + } + let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; + let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale: fit, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + for s in items { + r.shape(&mut ctx, &f, &rctx, s, view, 0); + } + Some(r.finish(ctx, w, h)) + }) +} + +/// Render one shape alone onto a transparent canvas (drag previews, copy as picture). +pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { + let rctx = Ctx::for_slide(pres, slide)?; + let s = slide.shape(id)?; + let x = resolve::xfrm(&rctx, s); + let b = x.bounds().inflate(8.0, 8.0); + let w = (b.width() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; + let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + r.shape(&mut ctx, &f, &rctx, s, view, 0); + Some(r.finish(ctx, w, h)) + }) +} diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs new file mode 100644 index 000000000..8310cf0c9 --- /dev/null +++ b/crates/render/src/stroke.rs @@ -0,0 +1,441 @@ +use super::geometry::{color, scale_of}; +use super::*; + +/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the +/// line belongs to; `path` itself when `None`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn stroke( + ctx: &mut RenderContext, + rctx: &Ctx, + line: &Line, + ph: Option, + path: &BezPath, + extent: Option<&BezPath>, + m: Affine, + lw: f64, + alpha: f64, +) { + use deckcraft_model::style::{LineCap, LineJoin}; + let cap = match line.cap.unwrap_or_default() { + LineCap::Flat => kurbo::Cap::Butt, + LineCap::Round => kurbo::Cap::Round, + LineCap::Square => kurbo::Cap::Square, + }; + let join = match line.join.unwrap_or_default() { + LineJoin::Round => kurbo::Join::Round, + LineJoin::Bevel => kurbo::Join::Bevel, + LineJoin::Miter => kurbo::Join::Miter, + }; + // Hairlines stay visible at any zoom. + let min_w = 0.5 / scale_of(m).max(1e-6); + let sw = lw.max(min_w); + // PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`). + let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0); + // Square caps on a dashed line square off only the line's own two ends (where a dash is + // drawn): each dash itself ends flat, as PowerPoint draws them. + let mut square_ends = None; + if let Some(d) = &line.dash { + let pat: Vec = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); + let pat = dash_with_caps(pat, cap, sw); + let pat = limit_dash_count(path, pat); + if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) { + if cap == kurbo::Cap::Square { + sk = sk.with_caps(kurbo::Cap::Butt); + square_ends = Some(square_dash_ends(path, &pat, sw)); + } + sk = sk.with_dashes(0.0, pat); + } + } + ctx.set_transform(m); + match &line.fill { + Some(Fill::Solid { color: c }) => { + ctx.set_paint(color(rctx.color(c, ph), alpha)); + } + Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)), + _ => ctx.set_paint(color(Rgba::BLACK, alpha)), + } + // Compound lines: each band stroked along the path moved to its place across the width. + let compound = line.compound.unwrap_or_default(); + if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 { + let tolerance = 0.1 / scale_of(m).max(1e-6); + for &(a, b) in compound::bands(compound) { + let d = (-0.5 + (a + b) / 2.0) * lw; + let mut band = sk.clone(); + band.width = (b - a) * lw; + ctx.set_stroke(band); + ctx.stroke_path(&compound::offset(path, d, tolerance)); + } + return; + } + ctx.set_stroke(sk); + ctx.stroke_path(path); + if let Some(ends) = square_ends { + ctx.fill_path(&ends); + } +} + +/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square +/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end +/// when the pattern is in a dash there. +fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { + use kurbo::{ParamCurve, ParamCurveArclen}; + let mut out = BezPath::new(); + let period: f64 = pattern.iter().sum(); + let mut cap = |at: Point, dir: Vec2| { + let n = Vec2::new(-dir.y, dir.x) * (width / 2.0); + let back = dir * (width / 2.0); + out.move_to(at + n); + out.line_to(at + n + back); + out.line_to(at - n + back); + out.line_to(at - n); + out.close_path(); + }; + let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| { + let d = seg.eval(t1) - seg.eval(t0); + let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() }; + if d.hypot() > 1e-9 { Some(d.normalize()) } else { None } + }; + let mut subs: Vec<(Vec, bool)> = vec![]; + let mut cur: Vec = vec![]; + let mut closed = false; + let mut last = Point::ZERO; + let mut start = Point::ZERO; + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + if !cur.is_empty() { + subs.push((std::mem::take(&mut cur), closed)); + } + closed = false; + last = p; + start = p; + continue; + } + PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), + PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), + PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), + PathEl::ClosePath => { + closed = true; + last = start; + continue; + } + }; + last = seg.end(); + cur.push(seg); + } + if !cur.is_empty() { + subs.push((cur, closed)); + } + for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) { + let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue }; + if let Some(d) = dir(first, 1e-3, 0.0) { + cap(first.start(), d); + } + let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum(); + if !(period > 0.0 && len.is_finite()) { + continue; + } + let mut pos = len % period; + // Ending exactly on a period boundary is the end of a gap. + let mut on = pos > period * 1e-6; + for v in pattern.iter().cycle().take(pattern.len()) { + if pos <= *v { + break; + } + pos -= v; + on = !on; + } + if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) { + cap(end.end(), d); + } + } + out +} + +/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the +/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs +/// across its width. +fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient { + let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0); + let bounds = paint::tile_box(bounds, g.tile); + let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect(); + paint::gradient(&g.shape, stops, bounds, alpha) +} + +/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As +/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a +/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid +/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a +/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see +/// `square_dash_ends`.) +pub(crate) fn dash_with_caps(mut pat: Vec, cap: kurbo::Cap, width: f64) -> Vec { + if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) { + return pat; + } + for (i, v) in pat.iter_mut().enumerate() { + *v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width }; + } + pat +} + +/// The most dashes one stroke is cut into. Stroking expands every dash into its own outline, so a +/// tiny dash on a huge shape (a 1 EMU-wide `custDash` on a 2147483647 EMU rectangle) would +/// otherwise need gigabytes and abort the app when the slide is drawn. Past this count the +/// pattern is scaled up evenly, which looks the same: the dashes are far below a pixel by then. +pub(crate) const MAX_DASHES: f64 = 20_000.0; + +pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec) -> Vec { + use kurbo::ParamCurveArclen; + let period: f64 = pat.iter().sum(); + if !(period.is_finite() && period > 0.0) { + return pat; + } + let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); + let dashes = len / period; + if !len.is_finite() { + return vec![]; + } + if dashes <= MAX_DASHES { + return pat; + } + let k = dashes / MAX_DASHES; + pat.into_iter().map(|v| v * k).collect() +} + +fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { + let els: Vec = path.elements().to_vec(); + let mut pts: Vec = vec![]; + for e in &els { + match e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(*p), + PathEl::QuadTo(a, b) => { + pts.push(*a); + pts.push(*b); + } + PathEl::CurveTo(a, b, c) => { + pts.push(*a); + pts.push(*b); + pts.push(*c); + } + PathEl::ClosePath => {} + } + } + if pts.len() < 2 { + return None; + } + let first = *pts.first()?; + let second = pts.iter().skip(1).find(|p| (**p - first).hypot() > 1e-6).copied().unwrap_or(*pts.get(1)?); + let last = *pts.last()?; + let prev = pts.iter().rev().skip(1).find(|p| (**p - last).hypot() > 1e-6).copied().unwrap_or(*pts.get(pts.len() - 2)?); + Some(((first, second), (last, prev))) +} + +/// An arrowhead's width and length for a line `lw` wide. PowerPoint sizes them in multiples of +/// the line width (small 2, medium 3, large 5), but never smaller than for a 2 pt line. +pub(crate) fn head_size(e: &deckcraft_model::style::LineEnd, lw: f64) -> (f64, f64) { + let sz = |s: &str| match s { + "sm" => 2.0, + "lg" => 5.0, + _ => 3.0, + }; + let k = lw.max(2.0); + (sz(&e.w) * k, sz(&e.len) * k) +} + +/// How far back from the tip an open arrowhead's legs (`lw` wide) meet along their centre lines: +/// PowerPoint puts the outside of the point, not the legs' centre lines, on the line's end. +fn open_arrow_setback(wl: f64, ll: f64, lw: f64) -> f64 { + let half = (wl / 2.0).atan2(ll); + if half.sin() > 1e-6 { (lw / 2.0 / half.sin()).min(ll) } else { 0.0 } +} + +/// How much of a `lw`-wide line to leave off at its start and end, under its arrowheads: up to +/// a triangle's base, into a stealth arrow's notch far enough for its barbs to cover the line's +/// corners, and to where an open arrow's legs meet. Ovals and diamonds sit on the end itself. +pub(crate) fn head_trims(line: &Line, lw: f64) -> (f64, f64) { + // A square cap reaches half a width past the line's end: leave that off too. + let cap = if line.cap == Some(deckcraft_model::style::LineCap::Square) { lw / 2.0 } else { 0.0 }; + let trim = |e: &Option| { + let Some(e) = e else { return 0.0 }; + let (wl, ll) = head_size(e, lw); + match e.kind.as_str() { + "triangle" => ll + cap, + "stealth" => (ll * (0.6 + 0.4 * lw / wl.max(1e-9))).min(ll) + cap, + "arrow" => open_arrow_setback(wl, ll, lw.max(0.75)) + cap, + _ => 0.0, + } + }; + (trim(&line.head), trim(&line.tail)) +} + +/// `path` with `start` left off the beginning of its first sub-path and `end` off the end of its +/// last (by arc length), where those are open. A sub-path shorter than its trims disappears. +pub(crate) fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath { + use kurbo::{ParamCurve, ParamCurveArclen}; + const ACC: f64 = 1e-3; + let mut subs: Vec<(Vec, bool)> = vec![]; + let (mut last, mut first) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + subs.push((vec![], false)); + last = p; + first = p; + continue; + } + PathEl::ClosePath => { + if let Some(s) = subs.last_mut() { + s.1 = true; + } + last = first; + continue; + } + PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), + PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), + PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), + }; + last = seg.end(); + if subs.is_empty() { + subs.push((vec![], false)); + } + if let Some(s) = subs.last_mut() { + s.0.push(seg); + } + } + if let Some((segs, false)) = subs.first_mut() + && start > 0.0 + { + let mut left = start; + let mut drop_count = 0; + for (i, seg) in segs.iter().enumerate() { + let l = seg.arclen(ACC); + if l > left { + let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0); + segs.drain(..i); + segs[0] = partial; + drop_count = 0; + break; + } + left -= l; + drop_count = i + 1; + } + if drop_count > 0 { + segs.drain(..drop_count); + } + } + if let Some((segs, false)) = subs.last_mut() + && end > 0.0 + { + let mut left = end; + while let Some(seg) = segs.last().copied() { + let l = seg.arclen(ACC); + if l > left { + if let Some(s1) = segs.last_mut() { + *s1 = seg.subsegment(0.0..seg.inv_arclen(l - left, ACC)); + } + break; + } + left -= l; + segs.pop(); + } + } + let mut out = BezPath::new(); + for (segs, closed) in subs { + let Some(s0) = segs.first() else { continue }; + out.move_to(s0.start()); + for seg in &segs { + match seg { + kurbo::PathSeg::Line(l) => out.line_to(l.p1), + kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), + kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), + } + } + if closed { + out.close_path(); + } + } + out +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, m: Affine, lw: f64) { + let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return }; + let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return }; + // The outline's ends in the space the arrowheads are sized and drawn in. + let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2); + let col = match &line.fill { + Some(Fill::Solid { color: c }) => rctx.color(c, ph), + _ => Rgba::BLACK, + }; + // A gradient line's heads take the gradient where they are. + let grad = match &line.fill { + Some(Fill::Gradient(g)) if !g.stops.is_empty() => { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + Some(line_gradient(rctx, g, ph, &moved, lw, 1.0)) + } + _ => None, + }; + let set_paint = |ctx: &mut RenderContext| match &grad { + Some(g) => ctx.set_paint(g.clone()), + None => ctx.set_paint(color(col, 1.0)), + }; + for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] { + let Some(e) = end else { continue }; + if e.kind == "none" { + continue; + } + let (wl, ll) = head_size(e, lw); + let dir = (tip - from).normalize(); + if !dir.x.is_finite() { + continue; + } + let n = Vec2::new(-dir.y, dir.x); + let base = tip - dir * ll; + let mut p = BezPath::new(); + match e.kind.as_str() { + "oval" => { + let c = kurbo::Ellipse::new(tip, (ll / 2.0, wl / 2.0), dir.y.atan2(dir.x)); + p = c.to_path(0.05); + } + "diamond" => { + p.move_to(tip + dir * ll / 2.0); + p.line_to(tip + n * wl / 2.0); + p.line_to(tip - dir * ll / 2.0); + p.line_to(tip - n * wl / 2.0); + p.close_path(); + } + "stealth" => { + p.move_to(tip); + p.line_to(base + n * wl / 2.0); + p.line_to(tip - dir * ll * 0.6); + p.line_to(base - n * wl / 2.0); + p.close_path(); + } + "arrow" => { + // Legs the line's width with round ends, meeting in a sharp point whose outside is + // on the line's end. + let leg = lw.max(0.75); + let back = dir * open_arrow_setback(wl, ll, leg); + let mut o = BezPath::new(); + o.move_to(base + n * wl / 2.0 - back); + o.line_to(tip - back); + o.line_to(base - n * wl / 2.0 - back); + ctx.set_transform(m); + set_paint(ctx); + ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&o); + continue; + } + _ => { + p.move_to(tip); + p.line_to(base + n * wl / 2.0); + p.line_to(base - n * wl / 2.0); + p.close_path(); + } + } + ctx.set_transform(m); + set_paint(ctx); + ctx.fill_path(&p); + } +} diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs new file mode 100644 index 000000000..095eedab1 --- /dev/null +++ b/crates/render/src/text_draw.rs @@ -0,0 +1,318 @@ +use super::geometry::{blur, color, pre_translate, scale_of}; +use super::opts::ParaState; +use super::*; + +/// The prompt shown in an empty placeholder, formatted like its content would be. +pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { + // Layout/master prompt text when the layout supplies a custom one. + let (lp, _) = resolve::parents(rctx, s); + let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty()); + if let Some(t) = custom { + return t.clone(); + } + if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) { + return TextBody::default(); + } + let txt = kind.prompt(); + if txt.is_empty() || kind.is_footer_kind() { + return TextBody::default(); + } + let mut t = TextBody::from_text(txt); + if matches!(kind, PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram | PhType::ClipArt) { + t.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + if let Some(p) = t.paragraphs.first_mut() { + p.props.align = Some(deckcraft_model::text::Align::Center); + p.props.bullet = Some(deckcraft_model::text::Bullet::None); + p.props.margin_left = Some(0.0); + p.props.indent = Some(0.0); + for r in &mut p.runs { + r.props.size = Some(14.0); + } + } + } + t +} + +thread_local! { + static GLYPH_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); +} + +pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { + let key = (face.id(), gid); + if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { + return p; + } + let p = FontDb::global().outline(face, gid); + GLYPH_CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 200_000 { + c.clear(); + } + c.insert(key, p.clone()); + }); + p +} + +/// Draw a text body into shape space `m` within the text rect. +pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option) { + let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); + draw_layout(ctx, &l, tr, m); +} + +/// Add transient list markers to an editor layout, using the document's own bullet glyphs. +pub(crate) fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) { + for mut run in l.empty_bullets.drain(..) { + run.color = Rgba::rgb(0xB3, 0xB3, 0xB3); + run.alpha = 1.0; + l.runs.push(run); + } +} + +/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local → +/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre. +/// +/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's +/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about +/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180° +/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this. +pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { + let c = tr.center().to_vec2(); + let mut t = m; + if m.determinant() < 0.0 && c.is_finite() { + t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c); + } + if rotation != 0.0 && rotation.is_finite() && c.is_finite() { + t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c); + } + t +} + +/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws. +pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option { + let bp = resolve::body(rctx, s); + let name = bp.warp.as_deref()?; + deckcraft_geom::warp::warp(name, &bp.warp_adj, tr) +} + +/// Draw an already laid-out text block. +pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { + draw_layout_with(ctx, l, tr, m, &[]); +} + +/// Draw a laid-out text block with per-paragraph animation states. +pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { + let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); + let m = text_transform(m, tr, l.rotation); + let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { + let ps = para(d.para); + if !ps.visible || ps.opacity <= 0.001 { + return; + } + ctx.set_transform(m * Affine::translate(ps.offset)); + ctx.set_paint(color(d.color, ps.opacity)); + fill_deco(ctx, d); + }; + for d in l.decos.iter().filter(|d| d.behind) { + deco(ctx, d); + } + // Text shadows, then glows, under all the text. + for pass in [TextEffect::Shadow, TextEffect::Glow] { + for run in &l.runs { + let ps = para(run.para); + if ps.visible && ps.opacity > 0.001 { + text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity); + } + } + } + for run in &l.runs { + if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + continue; + } + let ps = para(run.para); + if !ps.visible || ps.opacity <= 0.001 { + continue; + } + let m = m * Affine::translate(ps.offset); + // A gradient fill starts over on every line and spans that line's text (from its top to + // its bottom, from its first to its last character), as in PowerPoint. + let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new(); + for (gid, x, y) in &run.glyphs { + let at = m * glyph_place(run, *gid, *x, *y); + let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { + draw_glyph(ctx, run, *gid, at, ps.opacity); + continue; + }; + let li = line_at(l, run.para, *y); + if !grads.iter().any(|(i, _)| *i == li) { + let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; + let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); + let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr); + grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity))); + } + let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y)))); + draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill); + } + } + for d in l.decos.iter().filter(|d| !d.behind) { + deco(ctx, d); + } +} + +/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a +/// quarter turn back about its em box's centre when it stands upright in vertical text. +pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine { + let at = Affine::translate((x, y)); + match run.upright_centre(gid, x, y) { + Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at, + None => at, + } +} + +/// Fill an underline, strikethrough or highlight with the current paint and transform: its box, +/// its dashes, or its zigzag stroked. +pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { + if let Some((pts, w)) = d.wave() { + let mut path = BezPath::new(); + for (i, p) in pts.iter().enumerate() { + if i == 0 { + path.move_to(*p); + } else { + path.line_to(*p); + } + } + ctx.set_stroke(kurbo::Stroke::new(w.max(0.01))); + ctx.stroke_path(&path); + return; + } + for r in d.pieces() { + ctx.fill_rect(&r); + } +} + +/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). +fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { + if let Some(&(first, count)) = l.para_lines.get(para) { + if count == 0 { + return 0; + } + let end = (first + count).min(l.lines.len()); + if first < end { + return l.lines[first..end] + .iter() + .enumerate() + .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) + .map(|(i, _)| first + i) + .unwrap_or(0); + } + } + l.lines + .iter() + .enumerate() + .filter(|(_, li)| li.para == para) + .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) + .map(|(i, _)| i) + .unwrap_or(0) +} + +/// The box a line's text covers: its top and bottom, its first and last caret. +pub(crate) fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { + let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x))); + let r = Rect::new(x0, li.top, x1, li.bottom); + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + +#[derive(Clone, Copy, PartialEq)] +enum TextEffect { + Shadow, + Glow, +} + +/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. +fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { + let Some(fx) = run.effects else { return }; + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let px = scale_of(m); + let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) { + (TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0), + // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r + // past the glyph's edge and fades out by about two thirds of r. + (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68), + _ => return, + }; + let alpha = (c.a as f64 / 255.0) * opacity; + if alpha <= 0.0 { + return; + } + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma))); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + for (gid, x, y) in &run.glyphs { + let path = glyph(&run.face, *gid); + if path.elements().is_empty() { + continue; + } + // The offset is in slide space: a shadow falls the same way whatever the text's rotation. + let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k); + ctx.set_transform(at); + ctx.fill_path(&path); + if grow > 0.0 { + ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + ctx.pop_layer(); +} + +/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. +pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { + draw_glyph_with(ctx, run, gid, at, opacity, None); +} + +/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from +/// the glyph's origin to that space) instead of the run's colour. +fn draw_glyph_with( + ctx: &mut RenderContext, + run: &deckcraft_text::GlyphRun, + gid: u32, + at: Affine, + opacity: f64, + fill: Option<(&peniko::Gradient, Affine)>, +) { + let alpha = run.alpha * opacity; + if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + return; + } + let k = run.size / run.face.upem.max(1.0); + // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still + // strokes the plain glyph below. + if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { + return; + } + let path = glyph(&run.face, gid); + if path.elements().is_empty() { + return; + } + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + ctx.set_transform(at * skew * Affine::scale(k)); + if let Some((g, to_text)) = fill + && alpha > 0.0 + { + ctx.set_paint(g.clone()); + ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text); + ctx.fill_path(&path); + ctx.reset_paint_transform(); + } else if alpha > 0.0 && run.color.a > 0 { + ctx.set_paint(color(run.color, alpha)); + ctx.fill_path(&path); + if run.fake_bold { + ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + if let Some((oc, ow)) = run.outline { + ctx.set_paint(color(oc, opacity)); + ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } +} From 838d51ed594bd45c0cfed1811e164661354d5930 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:24:25 +0000 Subject: [PATCH 55/90] refactor(text): split lib.rs into {run,style,autonum,shape,symbol,breaking,layout}.rs --- crates/text/src/autonum.rs | 160 +++ crates/text/src/breaking.rs | 306 +++++ crates/text/src/layout.rs | 655 ++++++++++ crates/text/src/lib.rs | 2351 +---------------------------------- crates/text/src/run.rs | 226 ++++ crates/text/src/shape.rs | 458 +++++++ crates/text/src/style.rs | 227 ++++ crates/text/src/symbol.rs | 321 +++++ 8 files changed, 2379 insertions(+), 2325 deletions(-) create mode 100644 crates/text/src/autonum.rs create mode 100644 crates/text/src/breaking.rs create mode 100644 crates/text/src/layout.rs create mode 100644 crates/text/src/run.rs create mode 100644 crates/text/src/shape.rs create mode 100644 crates/text/src/style.rs create mode 100644 crates/text/src/symbol.rs diff --git a/crates/text/src/autonum.rs b/crates/text/src/autonum.rs new file mode 100644 index 000000000..c705f5490 --- /dev/null +++ b/crates/text/src/autonum.rs @@ -0,0 +1,160 @@ +fn roman(mut n: u32, upper: bool) -> String { + let table = [ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ]; + let mut s = String::new(); + if n == 0 || n > 3999 { + return n.to_string(); + } + for (v, r) in table { + while n >= v { + s.push_str(r); + n -= v; + } + } + if upper { s.to_uppercase() } else { s } +} + +fn alpha_num(n: u32, upper: bool) -> String { + if n == 0 { + return String::new(); + } + let n = n - 1; + let c = (b'a' + (n % 26) as u8) as char; + let reps = (n / 26 + 1).min(50); + let s: String = std::iter::repeat_n(c, reps as usize).collect(); + if upper { s.to_uppercase() } else { s } +} + +/// Letters of a numbering alphabet: 1 → the first, past the end the letter doubles ("aa"), as +/// the Latin alphabetic schemes do. +fn letters(list: &[char], n: u32) -> String { + let len = list.len().max(1) as u32; + let Some(i) = n.checked_sub(1) else { return String::new() }; + let c = list.get((i % len) as usize).copied().unwrap_or('?'); + std::iter::repeat_n(c, (i / len + 1).min(50) as usize).collect() +} + +/// `n` written with the digits `zero..zero+9` (Thai, Devanagari, full-width digits). +fn digits_in(n: u32, zero: char) -> String { + n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| char::from_u32(zero as u32 + v)).unwrap_or(d)).collect() +} + +const CJK_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + +/// Chinese numerals: 10 → 十, 11 → 十一, 25 → 二十五, 105 → 一百零五. +pub(crate) fn chinese_num(n: u32) -> String { + if n == 0 || n > 99_999 { + return n.to_string(); + } + let units = ['\0', '十', '百', '千', '万']; + let ds: Vec = n.to_string().chars().filter_map(|c| c.to_digit(10)).collect(); + let mut out = String::new(); + let mut zero = false; + for (i, d) in ds.iter().enumerate() { + let place = ds.len() - 1 - i; + if *d == 0 { + zero = true; + continue; + } + if zero { + out.push('零'); + zero = false; + } + // "十" alone for 10–19, not "一十". + if !(*d == 1 && place == 1 && i == 0) { + out.push(CJK_DIGITS.get(*d as usize).copied().unwrap_or('?')); + } + if let Some(u) = units.get(place).filter(|u| **u != '\0') { + out.push(*u); + } + } + out +} + +/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.". The East Asian, Thai, Hindi, Arabic +/// and Hebrew schemes are written as PowerPoint draws them: `ea1ChsPeriod` 10 → "十.", +/// `ea1JpnKorPeriod` 10 → "一〇." (digit by digit), `arabicDbPeriod` 13 → "13.", +/// `thaiAlphaParenR` 4 → "ง)", `hindiAlpha1Period` 1 → "क.", `arabic2Minus` 10 → "ي-" +/// (abjad order), `circleNumWdWhitePlain` 13 → "③" (those circles go to ten). +pub fn autonum(scheme: &str, n: u32) -> String { + const THAI: [char; 41] = [ + 'ก', 'ข', 'ค', 'ง', 'จ', 'ฉ', 'ช', 'ซ', 'ฌ', 'ญ', 'ฎ', 'ฏ', 'ฐ', 'ฑ', 'ฒ', 'ณ', 'ด', 'ต', 'ถ', 'ท', 'ธ', 'น', 'บ', 'ป', 'ผ', 'ฝ', 'พ', 'ฟ', + 'ภ', 'ม', 'ย', 'ร', 'ล', 'ว', 'ศ', 'ษ', 'ส', 'ห', 'ฬ', 'อ', 'ฮ', + ]; + const HINDI_VOWELS: [char; 11] = ['अ', 'आ', 'इ', 'ई', 'उ', 'ऊ', 'ऋ', 'ए', 'ऐ', 'ओ', 'औ']; + const HINDI_CONSONANTS: [char; 33] = [ + 'क', 'ख', 'ग', 'घ', 'ङ', 'च', 'छ', 'ज', 'झ', 'ञ', 'ट', 'ठ', 'ड', 'ढ', 'ण', 'त', 'थ', 'द', 'ध', 'न', 'प', 'फ', 'ब', 'भ', 'म', 'य', 'र', 'ल', + 'व', 'श', 'ष', 'स', 'ह', + ]; + const ARABIC_ALPHABET: [char; 28] = + ['ا', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي']; + const ARABIC_ABJAD: [char; 28] = + ['ا', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ']; + const HEBREW: [char; 22] = ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט', 'י', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ', 'ק', 'ר', 'ש', 'ת']; + let circled = |first: u32, count: u32| char::from_u32(first + n.saturating_sub(1) % count).map(String::from).unwrap_or_else(|| n.to_string()); + let core = if scheme.starts_with("romanUc") { + roman(n, true) + } else if scheme.starts_with("romanLc") { + roman(n, false) + } else if scheme.starts_with("alphaUc") { + alpha_num(n, true) + } else if scheme.starts_with("alphaLc") { + alpha_num(n, false) + } else if scheme.starts_with("circleNumWdWhite") { + return circled(0x2460, 10); + } else if scheme.starts_with("circleNumWdBlack") { + return circled(0x2776, 10); + } else if scheme.starts_with("circleNum") { + return char::from_u32(0x2460 + n.saturating_sub(1).min(19)).map(String::from).unwrap_or_else(|| n.to_string()); + } else if scheme.starts_with("ea1Chs") || scheme.starts_with("ea1Cht") { + chinese_num(n) + } else if scheme.starts_with("ea1Jpn") { + n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| CJK_DIGITS.get(v as usize).copied()).unwrap_or(d)).collect() + } else if scheme.starts_with("arabicDb") { + digits_in(n, '0') + } else if scheme.starts_with("thaiAlpha") { + letters(&THAI, n) + } else if scheme.starts_with("thaiNum") { + digits_in(n, '๐') + } else if scheme.starts_with("hindiAlpha1") { + letters(&HINDI_CONSONANTS, n) + } else if scheme.starts_with("hindiAlpha") { + letters(&HINDI_VOWELS, n) + } else if scheme.starts_with("hindiNum") { + digits_in(n, '०') + } else if scheme.starts_with("arabic1") { + letters(&ARABIC_ALPHABET, n) + } else if scheme.starts_with("arabic2") { + letters(&ARABIC_ABJAD, n) + } else if scheme.starts_with("hebrew2") { + letters(&HEBREW, n) + } else { + n.to_string() + }; + // East Asian schemes end with a full-width full stop. + let wide = scheme.starts_with("ea1") || scheme.starts_with("arabicDb"); + if scheme.ends_with("ParenBoth") { + format!("({core})") + } else if scheme.ends_with("ParenR") { + format!("{core})") + } else if scheme.ends_with("Period") { + format!("{core}{}", if wide { '.' } else { '.' }) + } else if scheme.ends_with("Minus") { + format!("{core}-") + } else { + core + } +} diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs new file mode 100644 index 000000000..133eabf50 --- /dev/null +++ b/crates/text/src/breaking.rs @@ -0,0 +1,306 @@ +use super::style::{Cell, ParaShaped}; +use super::*; + +pub(crate) fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { + let g = deckcraft_fonts::shape(face, s, &[], |c| c); + let k = size / face.upem.max(1.0); + let mut x = 0.0; + let mut out = vec![]; + for gl in g { + out.push((gl.gid, x + gl.x_offset as f64 * k)); + x += gl.x_advance as f64 * k; + } + (x, out) +} + +/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2 +/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18 +/// and 40 pt), whatever the font's own ascent and descent. +/// +/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or +/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics. +/// Keep it a single tunable value, and re-measure on the other platform before changing it. +pub(crate) const LINE_PITCH: f64 = 1.2; + +/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where +/// the font's Windows ascent and descent put the baseline. +pub(crate) fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) { + let h = size * LINE_PITCH; + (h * face.line_split, h * (1.0 - face.line_split)) +} + +/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between +/// syllables. +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}') +} + +/// East Asian commas and full stops, the punctuation PowerPoint lets hang past the end of a line +/// (`hangingPunct`). Closing brackets don't hang: "「。」" overflowing by its "」" moves to the next +/// line whole. +fn is_hanging_punct(c: char) -> bool { + matches!(c, '、' | '。' | ',' | '.' | '、' | '。') +} + +/// Small kana, the prolonged sound mark and iteration marks: PowerPoint keeps them off the start +/// of a line (strict Japanese line breaking), where UAX #14's default allows a break before them. +fn is_cj_nonstarter(c: char) -> bool { + matches!( + c, + 'ぁ' | 'ぃ' + | 'ぅ' + | 'ぇ' + | 'ぉ' + | 'っ' + | 'ゃ' + | 'ゅ' + | 'ょ' + | 'ゎ' + | 'ゕ' + | 'ゖ' + | 'ァ' + | 'ィ' + | 'ゥ' + | 'ェ' + | 'ォ' + | 'ッ' + | 'ャ' + | 'ュ' + | 'ョ' + | 'ヮ' + | 'ヵ' + | 'ヶ' + | 'ー' + | '々' + | '〻' + | 'ゝ' + | 'ゞ' + | 'ヽ' + | 'ヾ' + | '\u{31F0}'..='\u{31FF}' + | 'ァ'..='ー' + ) +} + +/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then +/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and +/// Korean breaks only between words. +pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) { + let n = text.chars().count(); + let mut allowed = vec![false; n + 1]; + let mut mandatory = vec![false; n + 1]; + let mut byte_to_char = std::collections::HashMap::new(); + for (ci, (b, _)) in text.char_indices().enumerate() { + byte_to_char.insert(b, ci); + } + byte_to_char.insert(text.len(), n); + for (b, op) in unicode_linebreak::linebreaks(text) { + if let Some(&ci) = byte_to_char.get(&b) { + if let Some(a) = allowed.get_mut(ci) { + *a = true; + } + if op == unicode_linebreak::BreakOpportunity::Mandatory + && let Some(m) = mandatory.get_mut(ci) + { + *m = true; + } + } + } + let chars: Vec = text.chars().collect(); + for (i, w) in chars.windows(2).enumerate() { + let (prev, next) = (w[0], w[1]); + // PowerPoint never breaks after a zero-width space, nor before an em dash (only after it). + let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) { + false + } else { + latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() + }; + if !keep + && !mandatory.get(i + 1).copied().unwrap_or(false) + && let Some(a) = allowed.get_mut(i + 1) + { + *a = false; + } + } + (allowed, mandatory) +} + +pub(crate) struct RawLine { + pub(crate) start: usize, + pub(crate) end: usize, + /// End including trailing spaces / the break char consumed. + next: usize, +} + +pub(crate) fn break_lines( + p: &ParaShaped, + first_w: f64, + rest_w: f64, + wrap: bool, + tab_stops: &[Tab], + default_tab: f64, + x0_first: f64, + x0_rest: f64, +) -> Vec { + let text: String = p.cells.iter().map(|c| c.ch).collect(); + let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); + let hang = p.props.hanging_punct.unwrap_or(false); + let n = p.cells.len(); + let mut lines = vec![]; + let mut start = 0; + if n == 0 { + return vec![RawLine { start: 0, end: 0, next: 0 }]; + } + while start < n { + let avail = if lines.is_empty() { first_w } else { rest_w }; + let x0 = if lines.is_empty() { x0_first } else { x0_rest }; + let mut x = 0.0; + let mut last_break: Option = None; + let mut i = start; + let mut end = n; + let mut next = n; + while i < n { + let c = p.cells.get(i).map(|c| c.ch).unwrap_or(' '); + if c == '\u{b}' || c == '\n' { + end = i; + next = i + 1; + break; + } + let adv = if c == '\t' { + tab_advance(x0 + x, tab_stops, default_tab, tab_segment(&p.cells, i)) + } else { + p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) + }; + let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j)); + if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) { + last_break = Some(i); + } + // Closing East Asian punctuation hangs past the end instead of wrapping + // (`hangingPunct`); the line breaks after it. + if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start { + end = i + 1; + next = i + 1; + break; + } + // A tab that would run past the end moves to the next line (and to its first stop there). + if wrap && c == '\t' && x + adv > avail + 0.01 && i > start { + end = i; + next = i; + while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') { + end -= 1; + } + break; + } + if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start { + // Break at the last opportunity, or force mid-word (between clusters). + let b = last_break + .filter(|b| *b > start) + .unwrap_or_else(|| (start + 1..=i).rev().find(|&j| breakable(j)).or_else(|| (i + 1..n).find(|&j| breakable(j))).unwrap_or(n)); + end = b; + next = b; + // Trailing spaces belong to the line but don't count. + while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') { + end -= 1; + } + break; + } + x += adv; + i += 1; + } + if i >= n { + end = n; + next = n; + } + lines.push(RawLine { start, end, next }); + if next <= start { + // No progress: force one char. + let last = lines.len() - 1; + let to = (start + 1..n).find(|&j| p.cells.get(j).is_none_or(|c| c.breakable(j))).unwrap_or(n); + if let Some(l) = lines.get_mut(last) { + l.end = to; + l.next = to; + } + start = to; + } else { + start = next; + } + if next == n && p.cells.last().is_some_and(|c| c.ch == '\u{b}') && start >= n { + // A trailing line break opens an empty last line. + lines.push(RawLine { start: n, end: n, next: n }); + } + } + lines +} + +/// How a tab stop aligns the text after it (`a:tab/@algn`). +#[derive(Clone, Copy, Debug, PartialEq)] +enum TabAlign { + Left, + Center, + Right, + /// The first "." of the text sits at the stop (text without one ends there). + Decimal, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Tab { + pos: f64, + align: TabAlign, +} + +pub(crate) fn tabs_of(props: &ParaProps) -> Vec { + props + .tabs + .as_ref() + .map(|t| { + t.iter() + .map(|s| Tab { + pos: s.pos, + align: match s.align.as_str() { + "ctr" => TabAlign::Center, + "r" => TabAlign::Right, + "dec" => TabAlign::Decimal, + _ => TabAlign::Left, + }, + }) + .collect() + }) + .unwrap_or_default() +} + +/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of +/// that text before its first ".". +pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { + let mut w = 0.0; + let mut dec = None; + for c in cells.iter().skip(i + 1) { + if matches!(c.ch, '\t'..='\u{b}') { + break; + } + if c.ch == '.' && dec.is_none() { + dec = Some(w); + } + w += c.adv; + } + (w, dec.unwrap_or(w)) +} + +/// Advance of a tab at `x` (from the start edge) to the next stop, placing the text after it +/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never +/// moving back, as PowerPoint does; past the last stop, to the next default stop. +pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { + for s in stops { + if s.pos > x + 0.5 { + let to = s.pos - x; + return match s.align { + TabAlign::Left => to, + TabAlign::Right => (to - seg.0).max(0.0), + TabAlign::Center => (to - seg.0 / 2.0).max(0.0), + TabAlign::Decimal => (to - seg.1).max(0.0), + }; + } + } + let d = if default_tab > 1.0 { default_tab } else { 72.0 }; + let next = ((x / d).floor() + 1.0) * d; + (next - x).max(1.0) +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs new file mode 100644 index 000000000..c6b5b1a08 --- /dev/null +++ b/crates/text/src/layout.rs @@ -0,0 +1,655 @@ +use super::breaking::{break_lines, line_metrics, measure_str, tab_advance, tab_segment, tabs_of}; +use super::run::{Deco, DecoLine, GlyphRun}; +use super::shape::{SUB_SUPER, Upright, shape_para}; +use super::style::{Opts, ParaShaped, spacing_pts}; +use super::*; + +#[cfg(test)] +thread_local! { + /// How many times `layout_scaled` ran on this thread (tests count autofit's passes). + pub(crate) static LAYOUT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Lay out a shape's text body. +pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout { + let bp = resolve::body(ctx, shape); + let autofit = bp.autofit.unwrap_or(AutoFit::None); + // Shrink on overflow starts from the scale the file was saved with (`fontScale`, + // `lnSpcReduction`), which is what PowerPoint draws; editing the text resets it. + let (scale0, reduce0, no_saved_scale) = match (autofit, opts.no_shrink) { + (AutoFit::Shrink { font_scale, line_reduction }, false) => ( + font_scale.filter(|s| s.is_finite()).map(|s| s.clamp(0.1, 1.0)).unwrap_or(1.0), + if line_reduction.is_finite() { line_reduction.clamp(0.0, 0.2) } else { 0.0 }, + font_scale.is_none(), + ), + _ => (1.0, 0.0, false), + }; + let mut l = layout_scaled(ctx, shape, body, &bp, opts, scale0, reduce0); + // A scale PowerPoint saved is drawn as it is: PowerPoint fits the text again only when it + // changes (and editing resets the saved scale), not when a file opens, even where our + // metrics would call it overflowing (trailing empty paragraphs, substituted fonts). + // A body whose file has no `fontScale` at all (``) is the schema default + // 100 % and is drawn as it is too, not refitted on open (#724). + let saved = no_saved_scale || scale0 < 1.0 || reduce0 > 0.0; + if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) + && l.overflow + && !saved + { + // Text that overflows even at the smallest scale gets that scale: every larger one + // overflows too, so searching would only lay it out a dozen more times (seconds for a + // body of tens of thousands of paragraphs, on every redraw of the slide). + let smallest = layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2); + if smallest.overflow { + return smallest; + } + // Binary search the largest scale that fits (steps like 90%, 80%… with line reduction). + let (mut lo, mut hi) = (0.1_f64, scale0); + let mut best = Some(smallest); + for _ in 0..12 { + let mid = (lo + hi) / 2.0; + let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2).max(reduce0); + let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red); + if t.overflow { + hi = mid; + } else { + lo = mid; + best = Some(t); + } + } + if let Some(b) = best { + l = b; + } + } + l +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { + #[cfg(test)] + LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); + let r = opts.rect; + let vert = bp.vert.unwrap_or(TextDir::Horizontal); + // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text + // stands every character upright, one under the other. + let upright_text = match vert { + TextDir::EaVertical => Upright::Cjk, + TextDir::Stacked | TextDir::StackedRtl => Upright::All, + _ => Upright::None, + }; + // Mongolian vertical and stacked text: turned like vert, their lines run from left to right + // (as PowerPoint draws both). + let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked); + let rotation = match vert { + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, + TextDir::Vertical270 => 270.0, + TextDir::Horizontal => 0.0, + } + bp.rot.unwrap_or(0.0); + // For vertical text, lay out in a box with swapped dimensions centred on the text rect. + let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 { + let c = r.center(); + Rect::new(c.x - r.height() / 2.0, c.y - r.width() / 2.0, c.x + r.height() / 2.0, c.y + r.width() / 2.0) + } else { + r + }; + let inner = Rect::new( + r.x0 + bp.inset_l.unwrap_or(7.2), + r.y0 + bp.inset_t.unwrap_or(3.6), + (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), + (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), + ); + // Text that follows a WordArt arch or circle is set on one line per paragraph. + let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); + let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); + let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; + let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); + let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); + + let mut out = TextLayout { inner, font_scale: scale, line_reduction: reduce, rotation, ..Default::default() }; + let mut y = 0.0; + let mut numbering: Vec = vec![0; 9]; + let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![]; + let mut prev_after = 0.0; + // How far the last line's full pitch reaches past the text (exact spacing; see below). + let mut tail = 0.0; + let first_last = bp.space_first_last.unwrap_or(false); + let mut shaped_paras: Vec = Vec::with_capacity(body.paragraphs.len()); + for (pi, para) in body.paragraphs.iter().enumerate() { + let lvl = para.level.min(8) as usize; + let pp = resolve::para(ctx, shape, para); + let number = if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) && !para.is_empty() { + if let Some(n) = numbering.get_mut(lvl) { + *n += 1; + } + for deeper in numbering.iter_mut().skip(lvl + 1) { + *deeper = 0; + } + numbering.get(lvl).copied() + } else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) { + // Preview the next number without consuming it: empty paragraphs do not advance + // numbering in the saved document or affect later populated paragraphs. + numbering.get(lvl).map(|n| n.saturating_add(1)) + } else { + if !para.is_empty() { + for n in numbering.iter_mut().skip(lvl) { + *n = 0; + } + } + None + }; + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text)); + } + for (pi, p) in shaped_paras.iter().enumerate() { + let props = &p.props; + let margin = props.margin_left.unwrap_or(0.0).max(0.0); + let indent = props.indent.unwrap_or(0.0); + let mr = props.margin_right.unwrap_or(0.0).max(0.0); + let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); + let first_x = if p.bullet.is_some() { + let bx = (margin + indent).max(0.0); + // A hanging bullet's text starts at the margin. When the hang reaches left of the box + // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the + // hang: the first line's text starts at -indent (later lines stay at the margin). + let stop = margin.max(-indent); + if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } + } else { + (margin + indent).max(0.0) + }; + let rest_x = margin; + let tabs = tabs_of(props); + let first_w = (col_w - first_x - mr).max(1.0); + let rest_w = (col_w - rest_x - mr).max(1.0); + let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x); + // The first paragraph's space before counts only when the body says so. + let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; + y += before.max(0.0) + prev_after; + let first_line_idx = all_lines.len(); + for (li, rl) in raw.iter().enumerate() { + // Line metrics: max ascent/descent over the line's styles (or the lead style if empty). + let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64); + for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { + if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { + let sz = st.size; + // The run's own fonts set the line; a fallback face (emoji, a symbol or a + // script the run's fonts lack) does not move the baseline in PowerPoint. + let own = Arc::ptr_eq(face, &st.face) + || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); + let (a, d) = line_metrics(if own { face } else { &st.face }, sz); + asc = asc.max(a); + desc = desc.max(d); + size = size.max(sz); + } + } + if size == 0.0 { + // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the + // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not + // by the paragraph's first run (#780). An empty *paragraph* already lands on + // `end_props` through `lead`, and keeps the same height here. + let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); + let (face, sz) = match brk { + Some(st) => (&st.face, st.size), + None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), + // Anything else empty (a line of tabs alone) keeps the old measure. + None => (&p.lead_face, p.lead_size), + }; + let (a, d) = line_metrics(face, sz); + asc = a; + desc = d; + size = sz; + } + let natural = (asc + desc).max(size * 1.0); + let lh = match props.line_spacing { + // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint + // applies `lnSpcReduction`. + Some(Spacing::Pct(f)) => natural * (f - reduce).max(f * 0.1).max(0.0), + Some(Spacing::Pts(v)) => v * (1.0 - reduce), + None => natural * (1.0 - reduce), + }; + let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; + let top = y; + // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the + // line pitch, and the text ends at the last line's descent rather than a full pitch. + let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); + let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; + tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; + y += lh; + let x0 = if li == 0 { first_x } else { rest_x }; + let avail = (col_w - x0 - mr).max(0.0); + let n = rl.end.saturating_sub(rl.start); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + // Logical advances; tabs measure from the start edge. + let mut adv = Vec::with_capacity(n); + let mut x = 0.0; + // A line that breaks at a soft hyphen ends with a visible hyphen. + let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); + for (k, c) in line_cells.iter().enumerate() { + let a = if shy_end && k + 1 == n { + p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) + } else if c.ch == '\t' { + tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) + } else { + c.adv + }; + adv.push(a); + x += a; + } + let width = x; + let align = props.align.unwrap_or(Align::Left); + let last_line = li + 1 == raw.len(); + let slack = (avail - width).max(0.0); + // Justification: kashida (justLow, Arabic), spaces, or every character. + let stretch = !last_line && wrap && slack > 0.0; + let mut kashida = vec![0.0; n]; + let mut filled = false; + if stretch && align == Align::JustLow { + let ops = kashida_opportunities(p, rl.start, rl.end); + if !ops.is_empty() { + let each = slack / ops.len() as f64; + for k in ops { + if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) { + *e = each; + *a += each; + } + } + filled = true; + } + } + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { + let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); + if spaces > 0 { + for (a, c) in adv.iter_mut().zip(line_cells) { + if c.ch == ' ' { + *a += slack / spaces as f64; + } + } + filled = true; + } + } + if align == Align::Distributed && n > 1 { + for a in adv.iter_mut().take(n - 1) { + *a += slack / (n - 1) as f64; + } + filled = true; + } + let line_w: f64 = adv.iter().sum(); + // The line box, in column coordinates: the start edge is the left one for LTR and the + // right one (S = W - rIns) for RTL paragraphs. + let (box_l, box_r) = if p.rtl { (mr, col_w - x0) } else { (x0, col_w - mr) }; + let left = if filled { + box_l + } else { + match align { + Align::Left => box_l, + Align::Right => box_r - line_w, + Align::Center => box_l + (box_r - box_l - line_w) / 2.0, + Align::Distributed if n <= 1 => box_l + slack / 2.0, + // Justified lines that aren't stretched (the last one) sit at the start edge. + Align::Justify | Align::JustLow | Align::Distributed if p.rtl => box_r - line_w, + Align::Justify | Align::JustLow | Align::Distributed => box_l, + } + }; + // Distance from the box's start edge to the text (bullets move with centred or + // end-aligned text). + let content_shift = if p.rtl { box_r - (left + line_w) } else { left - box_l }; + // Rules L1 + L2: this line's visual order. + let chars: Vec = line_cells.iter().map(|c| c.ch).collect(); + let raw_levels: Vec = line_cells.iter().map(|c| c.level).collect(); + let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl)); + let order = bidi::visual_order(&levels); + let mut edges = vec![(left, left); n]; + let mut vx = left; + for &k in &order { + let a = adv.get(k).copied().unwrap_or(0.0); + if let Some(e) = edges.get_mut(k) { + *e = (vx, vx + a); + } + vx += a; + } + let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1); + let mut caret_x: Vec = (0..n).map(|k| edges.get(k).map(|e| if odd(k) { e.1 } else { e.0 }).unwrap_or(left)).collect(); + caret_x.push(match n.checked_sub(1) { + Some(k) => edges.get(k).map(|e| if odd(k) { e.0 } else { e.1 }).unwrap_or(left), + None if p.rtl => left + line_w, + None => left, + }); + let bullet = if li == 0 { + p.bullet.as_ref().map(|(s, _)| { + // Centred and end-aligned text carries its bullet along. + let end_aligned = if p.rtl { align == Align::Left } else { align == Align::Right }; + let shifted = align == Align::Center || end_aligned; + let b = if shifted && p.cells.is_empty() { + content_shift + x0 - bullet_w + } else { + (margin + indent).max(0.0) + if shifted { content_shift } else { 0.0 } + }; + // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right. + let bx = if p.rtl { col_w - b - bullet_w } else { b }; + (s.clone(), bx, baseline) + }) + } else { + None + }; + out.content_width = out.content_width.max(width + x0 + mr); + let boundary: Vec = line_cells.iter().map(|c| c.boundary).collect(); + all_lines.push(( + LineInfo { + para: pi, + start: rl.start, + end: rl.end, + top, + baseline, + bottom: y, + caret_x, + column: 0, + rtl: p.rtl, + levels, + edges, + boundary, + extent: (left, left + line_w), + start_offset: x0, + }, + LinePlace { order, kashida }, + lh, + bullet, + )); + } + out.para_lines.push((first_line_idx, raw.len())); + prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); + } + // The text ends at the last line's descent under exact spacing; the last paragraph's space + // after counts only when the body says so. + let total = (y - tail).max(0.0) + if first_last { prev_after } else { 0.0 }; + out.content_height = total; + // Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the + // first column on the right). + let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; + let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap); + // Columns: distribute lines by height. + let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY }; + let mut col = 0usize; + let mut col_y0 = 0.0; + for (li, _, _, _) in all_lines.iter_mut() { + if ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < ncols { + col += 1; + col_y0 = li.top; + } + li.column = col; + if col > 0 { + let dy = col_y0; + li.top -= dy; + li.baseline -= dy; + li.bottom -= dy; + } + let cx = col_x(col); + if cx != 0.0 { + li.shift_x(cx); + } + } + let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total }; + out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5)); + let anchor = bp.anchor.unwrap_or(Anchor::Top); + let dy = match anchor { + Anchor::Top => 0.0, + Anchor::Middle => (inner.height() - used_h) / 2.0, + // PowerPoint draws justified and distributed anchoring at the bottom. + Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, + }; + // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put + // them, so the origin is set so the block's centre lands on the box's centre — a left-edge + // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink + // and stay out of the block. Overflowing content centres symmetrically past both edges. + // Not applied to vertical text (there the offset would run along the wrong axis) or + // multi-column bodies (centring unimplemented for them). + let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal); + let ox = if centre_block && !all_lines.is_empty() { + let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start); + let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1))); + if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } + } else { + inner.x0 + }; + let oy = inner.y0 + dy; + // Emit runs, in visual order. + for (line, place, _, bullet) in all_lines.iter_mut() { + line.top += oy; + line.baseline += oy; + line.bottom += oy; + line.shift_x(ox); + if lines_ltr { + // The line's place mirrored across the box: the first line ends up on the left. + let d = inner.y0 + inner.y1 - line.top - line.bottom; + line.top += d; + line.baseline += d; + line.bottom += d; + } + let Some(p) = shaped_paras.get(line.para) else { continue }; + if let Some((s, bx, _)) = bullet.take() + && let Some((_, st)) = p.bullet.as_ref() + { + let (_, g) = measure_str(&st.face, st.size, &s); + let run = GlyphRun { + face: st.face.clone(), + size: st.size, + color: st.color, + glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(), + cells: vec![], + fake_bold: false, + fake_italic: false, + outline: None, + para: line.para, + chars: (0, 0), + link: false, + alpha: st.alpha, + gradient: None, + effects: None, + upright: false, + }; + if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() { + out.empty_bullets.push(run); + } else { + out.runs.push(run); + } + } + // Group visually consecutive cells by (style, face). + let mut cur: Option = None; + let mut cur_key = (usize::MAX, usize::MAX, false, false); + for &k in &place.order { + let ci = line.start + k; + let Some(cell) = p.cells.get(ci) else { continue }; + let Some(st) = p.styles.get(cell.style) else { continue }; + let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; + let base_y = line.baseline - st.baseline * st.size; + let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; + let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; + // Small capitals are drawn smaller than the capitals beside them: a run of their own. + let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright); + if key != cur_key { + if let Some(r) = cur.take() { + out.runs.push(r); + } + let Some(face) = p.faces.get(cell.face) else { continue }; + cur = Some(GlyphRun { + face: face.clone(), + size, + color: st.color, + glyphs: vec![], + cells: vec![], + fake_bold: st.fake_bold, + fake_italic: st.fake_italic, + outline: st.outline, + para: line.para, + chars: (ci, ci + 1), + link: st.link, + alpha: st.alpha, + gradient: st.gradient.clone(), + effects: st.effects, + upright: cell.upright, + }); + cur_key = key; + } + if let Some(r) = cur.as_mut() { + r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); + if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { + // The soft hyphen a line breaks at is drawn as a hyphen. + if let Some(f) = p.faces.get(cell.face) { + r.glyphs.push((f.glyph_for('-'), cx, base_y)); + r.cells.push((ci, cell.ch)); + } + } else if cell.head == ci && !cell.glyphs.is_empty() { + // The cluster's glyphs start at its leftmost cell; kashida (right-to-left + // only) fills the gap on the cluster's left with tatweel glyphs. + let cells_of = + (k..line.end.saturating_sub(line.start)).take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci)); + let (mut cl, mut kash) = (cx, 0.0); + for j in cells_of { + cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx)); + kash += place.kashida.get(j).copied().unwrap_or(0.0); + } + if kash > 0.0 { + let face = p.faces.get(cell.face); + let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0); + let tw = face.map(|f| f.advance(tg) * size / f.upem.max(1.0)).unwrap_or(0.0); + if tg != 0 && tw > 0.0 { + let count = ((kash / tw).ceil() as usize).clamp(1, 256); + for i in 0..count { + r.glyphs.push((tg, cl + (i as f64 * tw).min((kash - tw).max(0.0)), base_y)); + r.cells.push((ci, cell.ch)); + } + } + } + for (gid, gx, gy) in &cell.glyphs { + r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); + r.cells.push((ci, cell.ch)); + } + } + } + let adv = cx1 - cx; + if adv <= 0.0 { + continue; + } + if let Some(hl) = st.highlight { + out.decos.push(Deco { + rect: Rect::new(cx, line.top, cx1, line.bottom), + color: hl, + behind: true, + para: line.para, + line: DecoLine::Solid, + }); + } + if !cell.ch.is_whitespace() || st.underline.is_some() { + if let Some((uc, u)) = st.underline { + // At the underline position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); + for (y0, y1, kind) in u.boxes(p, t) { + out.decos.push(Deco { + rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), + color: uc, + behind: false, + para: line.para, + line: kind, + }); + } + } + if let Some(sk) = st.strike { + // At the strikeout position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let t = (st.face.strikeout.1 * k).max(0.5); + let sy = line.baseline - st.face.strikeout.0 * k; + out.decos.push(Deco { + rect: Rect::new(cx, sy, cx1, sy + t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); + if sk == Strike::Double { + out.decos.push(Deco { + rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); + } + } + } + } + if let Some(r) = cur.take() { + out.runs.push(r); + } + } + out.boundaries = shaped_paras + .iter() + .map(|p| { + let mut b: Vec = p.cells.iter().map(|c| c.boundary).collect(); + b.push(true); + if let Some(f) = b.first_mut() { + *f = true; + } + b + }) + .collect(); + out.lines = all_lines.into_iter().map(|(l, ..)| l).collect(); + // Merge adjacent decorations of the same colour on the same line. + out.decos = merge_decos(std::mem::take(&mut out.decos)); + out +} + +fn merge_decos(v: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(v.len()); + for d in v { + // Lines drawn together (a double underline, a strikethrough) interleave: look a few back. + if let Some(last) = out.iter_mut().rev().take(4).find(|last| { + last.color == d.color + && last.behind == d.behind + && last.line == d.line + && (last.rect.y0 - d.rect.y0).abs() < 0.01 + && (last.rect.y1 - d.rect.y1).abs() < 0.01 + && (last.rect.x1 - d.rect.x0).abs() < 0.5 + }) { + last.rect.x1 = d.rect.x1; + continue; + } + out.push(d); + } + out +} + +/// How a line's cells are placed: visual order (indices from the line start) and kashida widths +/// (the boxes, with justification, are in [`LineInfo::edges`]). +struct LinePlace { + order: Vec, + kashida: Vec, +} + +/// Kashida opportunities on a line (indices from the line start), at most one per word (its last +/// join, where elongation reads most naturally): the last cell of a right-to-left Arabic grapheme +/// that joins the next letter, in a face with a tatweel glyph, when the two aren't one ligature. +fn kashida_opportunities(p: &ParaShaped, start: usize, end: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut word_has = false; + for i in start..end { + let Some(c) = p.cells.get(i) else { continue }; + if c.ch.is_whitespace() { + word_has = false; + continue; + } + if !c.boundary || c.level % 2 == 0 || !joins_following(c.ch) { + continue; + } + // The next grapheme on the line. + let Some(nx) = (i + 1..end).find(|&j| p.cells.get(j).is_some_and(|d| d.boundary)) else { continue }; + let Some(d) = p.cells.get(nx) else { continue }; + let same_cluster = d.head == c.head || d.head != nx; + let tatweel = p.faces.get(c.face).is_some_and(|f| f.glyph_for('\u{0640}') != 0); + if joins_preceding(d.ch) && d.face == c.face && !same_cluster && tatweel { + // A later join in the same word replaces the earlier one. + if word_has { + out.pop(); + } + out.push(nx - 1 - start); + word_has = true; + } + } + out +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e70c33046..9e03a2b4d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -34,230 +34,33 @@ use deckcraft_model::text::{Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Par use deckcraft_model::{Fill, Shape}; use unicode_segmentation::UnicodeSegmentation; -/// A run of glyphs in one face, size and colour. -#[derive(Clone)] -pub struct GlyphRun { - pub face: Arc, - /// Font size in points (after autofit scaling). - pub size: f64, - pub color: Rgba, - /// Glyph id and pen position of its origin (baseline), in shape-local points. - pub glyphs: Vec<(u32, f64, f64)>, - /// Per glyph: the paragraph character it draws (index, char); empty for bullets. - pub cells: Vec<(usize, char)>, - /// Draw a heavier stroke because the family has no bold face. - pub fake_bold: bool, - /// Slant because the family has no italic face. - pub fake_italic: bool, - pub outline: Option<(Rgba, f64)>, - /// Paragraph and character range this run covers (for hyperlinks, selection colours). - pub para: usize, - pub chars: (usize, usize), - pub link: bool, - /// Opacity of the fill on top of `color`'s own alpha (0 for no fill). - pub alpha: f64, - pub gradient: Option, - /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). - pub effects: Option, - /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn - /// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is - /// laid out turned a quarter turn. - pub upright: bool, -} +mod autonum; +mod breaking; +mod layout; +mod run; +mod shape; +mod style; +mod symbol; -/// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the -/// em box from 0.88 em above to 0.12 em below it). -pub const UPRIGHT_CENTRE: f64 = 0.38; - -impl GlyphRun { - /// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box, - /// `advance` wide (`None` when the run is not upright). - pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> { - if !self.upright { - return None; - } - let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0); - Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE)) - } -} - -/// A text run's shadow and glow, colours resolved. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RunEffects { - pub shadow: Option, - /// Glow colour (with its alpha) and radius in points. - pub glow: Option<(Rgba, f64)>, -} - -/// An outer shadow: colour (with its alpha), blur radius and offset in points. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TextShadow { - pub color: Rgba, - pub blur: f64, - pub dx: f64, - pub dy: f64, -} - -impl std::fmt::Debug for GlyphRun { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GlyphRun").field("family", &self.face.family).field("size", &self.size).field("glyphs", &self.glyphs.len()).finish() - } -} - -/// An underline, strikethrough or highlight box. -#[derive(Clone, Debug)] -pub struct Deco { - pub rect: Rect, - pub color: Rgba, - /// Highlights draw behind text. - pub behind: bool, - /// Paragraph the decoration belongs to (by-paragraph animation). - pub para: usize, - /// How the line fills `rect`. - pub line: DecoLine, -} - -/// How an underline fills its box. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DecoLine { - /// The whole box. - #[default] - Solid, - /// Dashes or dots across the box's height, from its left end. - Dashed(Dash), - /// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`. - Wave { stroke: f64, period: f64 }, -} - -/// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured -/// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three -/// thicknesses long with gaps of two. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Dash { - Dot, - Dash, - Long, - DotDash, - DotDotDash, -} - -impl Dash { - /// On, off, on, off… lengths in thicknesses. - fn pattern(self) -> &'static [f64] { - match self { - Dash::Dot => &[1.0, 1.0], - Dash::Dash => &[3.0, 2.0], - Dash::Long => &[6.0, 2.0], - Dash::DotDash => &[3.0, 2.0, 1.0, 2.0], - Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0], - } - } -} - -impl Deco { - /// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy - /// line gives a thin box along its middle, for drawing that can't stroke a path. - pub fn pieces(&self) -> Vec { - let r = self.rect; - match self.line { - DecoLine::Solid => vec![r], - DecoLine::Wave { stroke, .. } => { - let c = (r.y0 + r.y1) / 2.0; - vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)] - } - DecoLine::Dashed(d) => { - let unit = r.height().max(0.1); - let pat = d.pattern(); - let mut out = Vec::new(); - let mut x = r.x0; - let mut k = 0usize; - while x < r.x1 && out.len() < 10_000 { - let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit; - if k.is_multiple_of(2) { - out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1)); - } - x += len; - k += 1; - } - out - } - } - } - - /// The zigzag of a wavy underline, left to right from the box's top, and its stroke width; - /// None for other lines. - pub fn wave(&self) -> Option<(Vec, f64)> { - let DecoLine::Wave { stroke, period } = self.line else { return None }; - let r = self.rect; - let half = (period / 2.0).max(0.1); - let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000); - let pts = (0..=n) - .map(|i| { - let x = (r.x0 + i as f64 * half).min(r.x1); - Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 }) - }) - .collect(); - Some((pts, stroke)) - } -} - -/// An underline style (`a:rPr/@u`). -#[derive(Clone, Copy, Debug, PartialEq)] -enum Underline { - Single, - Double, - Heavy, - /// Dotted or dashed, heavy or not. - Dashed(Dash, bool), - /// Wavy, heavy or not. - Wavy(bool), -} - -impl Underline { - fn from_xml(u: &str) -> Option { - Some(match u { - "none" => return None, - // PowerPoint underlines the spaces of a "words" underline too. - "words" => Underline::Single, - // At text sizes PowerPoint's double wave reads as two straight lines. - "dbl" | "wavyDbl" => Underline::Double, - "heavy" => Underline::Heavy, - "dotted" => Underline::Dashed(Dash::Dot, false), - "dottedHeavy" => Underline::Dashed(Dash::Dot, true), - "dash" => Underline::Dashed(Dash::Dash, false), - "dashHeavy" => Underline::Dashed(Dash::Dash, true), - "dashLong" => Underline::Dashed(Dash::Long, false), - "dashLongHeavy" => Underline::Dashed(Dash::Long, true), - "dotDash" => Underline::Dashed(Dash::DotDash, false), - "dotDashHeavy" => Underline::Dashed(Dash::DotDash, true), - "dotDotDash" => Underline::Dashed(Dash::DotDotDash, false), - "dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true), - "wavy" => Underline::Wavy(false), - "wavyHeavy" => Underline::Wavy(true), - _ => Underline::Single, - }) - } - - /// The boxes of this underline below the baseline (top, bottom, fill), from the font's - /// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export - /// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two - /// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every - /// 3.9t. - fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> { - let heavy = (p - t, p + 0.55 * t); - match self { - Underline::Single => vec![(p, p + t, DecoLine::Solid)], - Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)], - Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)], - Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))], - Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))], - Underline::Wavy(h) => { - let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 }; - vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })] - } - } - } -} +pub use autonum::autonum; +#[cfg(test)] +use autonum::chinese_num; +#[cfg(test)] +use breaking::{LINE_PITCH, break_opportunities, measure_str}; +#[cfg(test)] +pub(crate) use layout::LAYOUT_PASSES; +pub use layout::layout; +#[cfg(test)] +use layout::layout_scaled; +pub use run::{Dash, Deco, DecoLine, GlyphRun, RunEffects, TextShadow, UPRIGHT_CENTRE}; +#[cfg(test)] +use shape::{SUB_SUPER, caps_char, is_upright, shaping_features}; +#[cfg(test)] +use style::Cell; +pub use style::{Fields, NoFields, Opts}; +#[cfg(test)] +use symbol::{covers_symbol, is_symbol_font, symbol_font_char, symbol_font_draw, symbol_unicode, symbol_unicode_alts}; +pub use symbol::{map_symbol_bullet, symbol_char}; #[derive(Clone, Debug, Default)] pub struct LineInfo { @@ -316,2108 +119,6 @@ pub struct TextLayout { pub boundaries: Vec>, } -/// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text. -pub trait Fields { - fn field(&self, kind: &str) -> Option; - /// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are - /// written the language's way. - fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { - let _ = lang; - self.field(kind) - } -} - -pub struct NoFields; -impl Fields for NoFields { - fn field(&self, kind: &str) -> Option { - if kind == "slidenum" { Some("1".into()) } else { None } - } -} - -pub struct Opts<'a> { - /// The text box: the preset's text rectangle in shape-local space. - pub rect: Rect, - pub fields: &'a dyn Fields, - /// Draw this text instead of the body (placeholder prompts), in this colour. - pub prompt_color: Option, - /// Ignore autofit shrinking (used to measure natural height). - pub no_shrink: bool, -} - -struct Style { - face: Arc, - size: f64, - color: Rgba, - alpha: f64, - fake_bold: bool, - fake_italic: bool, - baseline: f64, - spacing: f64, - caps: Caps, - underline: Option<(Rgba, Underline)>, - strike: Option, - highlight: Option, - outline: Option<(Rgba, f64)>, - link: bool, - gradient: Option, - effects: Option, - /// "Regular", "Bold"… for the slot and fallback faces. - style_name: &'static str, - /// BCP 47 language (`a:rPr/@lang`), for shaping. - lang: Option, - /// The `a:ea` and `a:cs` families (theme references resolved), if the document names one. - ea: Option, - cs: Option, - /// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font - /// characters) are drawn in it. - sym: Option, - /// Heading font (`+mj-…`): the theme's major font set applies. - major: bool, - /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it. - kern: bool, -} - -/// One character of a paragraph after shaping. -#[derive(Clone)] -struct Cell { - ch: char, - style: usize, - face: usize, - /// Resolved bidi embedding level (before the line rules). - level: u8, - /// Glyphs of the shaping cluster this cell heads, from the cluster's visual left edge (gid, - /// x, y). Empty for the other cells of a cluster. - glyphs: Vec<(u32, f64, f64)>, - /// This character's advance: its share of the cluster (ligature carets); 0 inside a grapheme. - adv: f64, - /// Index of the cell heading this character's shaping cluster. - head: usize, - /// A grapheme starts here: carets stand and lines break only before such cells. - boundary: bool, - /// Drawn upright in East Asian vertical text. - upright: bool, -} - -impl Cell { - /// A line may start before this cell: a grapheme that starts a shaping cluster. - fn breakable(&self, i: usize) -> bool { - self.boundary && self.head == i - } -} - -struct ParaShaped { - cells: Vec, - styles: Vec