mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
refactor(ui-egui): split show.rs into show/{render,keys,ui,media,presenter,zoom}.rs
This commit is contained in:
+20
-1845
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(), ×, 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, ×, 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}));
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user