refactor(ui-egui): split show.rs into show/{render,keys,ui,media,presenter,zoom}.rs

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent ada66524a2
commit 99688fe45a
7 changed files with 1905 additions and 1845 deletions
+20 -1845
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -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<ShowAction>,
) -> 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
}
+254
View File
@@ -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<ShapeId> = 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<HitShape>,
/// Whether each of `shapes` is currently shown (an animation may have hidden it).
visible: Vec<bool>,
pub(super) pick: Option<Pick>,
}
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<Under> {
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<HitShape> = 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<bool> = 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<ShapeId> {
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)
}
+258
View File
@@ -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) })
}
}
+210
View File
@@ -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<ShapeId>,
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<ShapeId>,
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<ShapeId> {
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<ShapeId> {
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<Presentation>, next: usize, size: (u32, u32)) -> Option<Prefetch> {
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<egui::TextureId>, 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<usize>, to: usize, size: (u32, u32)) -> [Option<TextureHandle>; 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<TextureHandle> {
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)
}
+612
View File
@@ -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<ShowAction> = 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<ShapeId> = 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(), &times, 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<deckcraft_render::TextMorph> = 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<deckcraft_render::PathMorph> = 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<ShapeId> = 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, &times, 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<Pos2> = 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}));
}
}
+403
View File
@@ -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<usize> {
let raw = std::mem::take(&mut self.goto);
raw.parse::<usize>().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<f32> {
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<usize> {
(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<Rect> {
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<Rect> = 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<usize> {
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) },
);
}
}