From 99688fe45a70781198bddbfc3541f2f456a67e89 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:44:22 +0000 Subject: [PATCH] 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 403b6b59..342dfd2c 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 00000000..71322923 --- /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 00000000..72838396 --- /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 00000000..17f78fa5 --- /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 00000000..bf278064 --- /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 00000000..e769d66f --- /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 00000000..97d306a4 --- /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) }, + ); + } +}