mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-30 05:27:06 -06:00
Merge PR #656 from NikitkaRa: Presenter view: pause and reset the timer, and notes size
# Conflicts: # crates/ui-egui/src/show.rs
This commit is contained in:
@@ -80,7 +80,8 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
|
||||
let h = d.doc.slide_size.height.max(1.0) as f32;
|
||||
(d.doc.slides.len(), d.doc.slide_size.width as f32 / h)
|
||||
}).unwrap_or((0, 16.0 / 9.0));
|
||||
s.showing_value(n, aspect, ctx.content_rect())
|
||||
let now = ctx.input(|i| i.time);
|
||||
s.showing_value(n, aspect, ctx.content_rect(), now)
|
||||
}),
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
|
||||
"status": app.status.as_ref().map(|s| s.0.clone()),
|
||||
|
||||
+167
-14
@@ -45,6 +45,10 @@ pub struct Show {
|
||||
goto: String,
|
||||
started: bool,
|
||||
show_start: f64,
|
||||
/// Elapsed time in presenter view. Pausing it does not pause the show.
|
||||
timer: PresenterTimer,
|
||||
/// Speaker-notes size in presenter view, in points.
|
||||
notes_px: f32,
|
||||
timings: Vec<(usize, f64)>,
|
||||
last_move: f64,
|
||||
pub ended: bool,
|
||||
@@ -188,6 +192,8 @@ impl Show {
|
||||
goto: String::new(),
|
||||
started: false,
|
||||
show_start: 0.0,
|
||||
timer: PresenterTimer::start(0.0),
|
||||
notes_px: NOTES_DEFAULT,
|
||||
timings: vec![],
|
||||
last_move: 0.0,
|
||||
ended: false,
|
||||
@@ -456,6 +462,10 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64)
|
||||
E => show.strokes.clear(),
|
||||
G if plain(m) => show.grid = true,
|
||||
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 => {
|
||||
show.goto.push_str(k.name());
|
||||
}
|
||||
@@ -488,6 +498,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
show.slide_start = now;
|
||||
show.step_start = now;
|
||||
show.show_start = now;
|
||||
show.timer = PresenterTimer::start(now);
|
||||
show.last_move = now;
|
||||
if !show.reading {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
|
||||
@@ -1288,7 +1299,14 @@ impl Show {
|
||||
self.zoom.map(|z| (z.nx, z.ny, z.scale))
|
||||
}
|
||||
|
||||
pub fn showing_value(&self, n_slides: usize, aspect: f32, full: Rect) -> serde_json::Value {
|
||||
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,
|
||||
@@ -1297,6 +1315,9 @@ impl Show {
|
||||
"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,
|
||||
});
|
||||
if self.grid {
|
||||
let slides = grid_indices(n_slides);
|
||||
@@ -1319,6 +1340,74 @@ impl Show {
|
||||
const ZOOM_SCALE: f32 = 2.0;
|
||||
const GRID_CAP: usize = 48;
|
||||
const TOOL_BAR_W: f32 = 520.0;
|
||||
const NOTES_DEFAULT: f32 = 18.0;
|
||||
const NOTES_MIN: f32 = 12.0;
|
||||
const NOTES_MAX: f32 = 48.0;
|
||||
const NOTES_STEP: f32 = 4.0;
|
||||
|
||||
/// Presenter-view elapsed time. Pausing freezes the number; the show keeps playing.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PresenterTimer {
|
||||
origin: f64,
|
||||
frozen: f64,
|
||||
paused: bool,
|
||||
}
|
||||
|
||||
impl PresenterTimer {
|
||||
fn start(now: f64) -> Self {
|
||||
PresenterTimer { origin: finite_time(now), frozen: 0.0, paused: false }
|
||||
}
|
||||
|
||||
fn elapsed(self, now: f64) -> f64 {
|
||||
if self.paused {
|
||||
return finite_secs(self.frozen);
|
||||
}
|
||||
let dt = now - self.origin;
|
||||
finite_secs(self.frozen + if dt.is_finite() && dt > 0.0 { dt } else { 0.0 })
|
||||
}
|
||||
|
||||
fn paused(self) -> bool {
|
||||
self.paused
|
||||
}
|
||||
|
||||
fn toggle(&mut self, now: f64) {
|
||||
if self.paused {
|
||||
self.origin = finite_time(now);
|
||||
self.paused = false;
|
||||
} else {
|
||||
self.frozen = self.elapsed(now);
|
||||
self.paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero the elapsed time. A paused timer stays paused at zero.
|
||||
fn reset(&mut self, now: f64) {
|
||||
self.frozen = 0.0;
|
||||
self.origin = finite_time(now);
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_time(now: f64) -> f64 {
|
||||
if now.is_finite() { now } else { 0.0 }
|
||||
}
|
||||
|
||||
fn finite_secs(n: f64) -> f64 {
|
||||
if n.is_finite() && n >= 0.0 { n } else { 0.0 }
|
||||
}
|
||||
|
||||
fn format_elapsed(secs: f64) -> String {
|
||||
let s = finite_secs(secs) as u64;
|
||||
let s = s.min(99 * 3600 + 59 * 60 + 59);
|
||||
format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60)
|
||||
}
|
||||
|
||||
/// Local wall clock for presenter view. `--:--` where there is no clock (wasm).
|
||||
fn wall_clock() -> String {
|
||||
match deckcraft_text::datetime::DateTime::now_local() {
|
||||
Some(t) if t.hour < 24 && t.minute < 60 => format!("{:02}:{:02}", t.hour, t.minute),
|
||||
_ => "--:--".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Zoom {
|
||||
@@ -1552,7 +1641,9 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
}
|
||||
let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone());
|
||||
let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default();
|
||||
let elapsed = now - show.show_start;
|
||||
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![];
|
||||
ctx.show_viewport_immediate(
|
||||
egui::ViewportId::from_hash_of("deckcraft_presenter"),
|
||||
@@ -1561,18 +1652,18 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
if !keyboard(ui.ctx(), show, doc, now) {
|
||||
*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!(
|
||||
"{:02}:{:02}:{:02}",
|
||||
(elapsed / 3600.0) as u32,
|
||||
(elapsed / 60.0) as u32 % 60,
|
||||
elapsed as u32 % 60
|
||||
))
|
||||
.size(22.0)
|
||||
.color(Color32::WHITE),
|
||||
);
|
||||
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| {
|
||||
@@ -1588,6 +1679,12 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(if paused { "Resume" } else { "Pause" }).clicked() {
|
||||
show.timer.toggle(now);
|
||||
}
|
||||
if ui.button("Reset").clicked() {
|
||||
show.timer.reset(now);
|
||||
}
|
||||
if ui.button(if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() {
|
||||
toggle_blank(show, Color32::BLACK);
|
||||
}
|
||||
@@ -1642,10 +1739,24 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170));
|
||||
}
|
||||
}
|
||||
let notes_rect = Rect::from_min_max(pos2(right.min.x, nr.max.y + 16.0), right.max);
|
||||
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| {
|
||||
ui.label(egui::RichText::new(if notes.is_empty() { "No notes." } else { ¬es }).size(18.0).color(Color32::from_gray(230)));
|
||||
ui.label(
|
||||
egui::RichText::new(if notes.is_empty() { "No notes." } else { ¬es }).size(show.notes_px).color(Color32::from_gray(230)),
|
||||
);
|
||||
});
|
||||
});
|
||||
if ui.input(|i| i.viewport().close_requested()) {
|
||||
@@ -1943,4 +2054,46 @@ mod tests {
|
||||
assert!(show.apply(ShowAction::Slide(1), &doc, 1.0));
|
||||
assert!(show.zoom.is_none(), "a new slide is shown whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presenter_timer_pauses_resets_and_notes_size_clamps() {
|
||||
let mut t = PresenterTimer::start(10.0);
|
||||
assert!((t.elapsed(12.5) - 2.5).abs() < 1e-9);
|
||||
t.toggle(15.0);
|
||||
assert!(t.paused());
|
||||
assert!((t.elapsed(100.0) - 5.0).abs() < 1e-9);
|
||||
t.reset(100.0);
|
||||
assert!(t.paused());
|
||||
assert_eq!(t.elapsed(200.0), 0.0);
|
||||
t.toggle(200.0);
|
||||
assert!(!t.paused());
|
||||
assert!((t.elapsed(203.0) - 3.0).abs() < 1e-9);
|
||||
t.reset(203.0);
|
||||
assert!(!t.paused());
|
||||
assert!((t.elapsed(204.0) - 1.0).abs() < 1e-9);
|
||||
let bad = PresenterTimer::start(f64::NAN);
|
||||
assert_eq!(bad.elapsed(f64::INFINITY), 0.0);
|
||||
assert_eq!(format_elapsed(3661.4), "01:01:01");
|
||||
assert_eq!(format_elapsed(-1.0), "00:00:00");
|
||||
assert_eq!(format_elapsed(f64::NAN), "00:00:00");
|
||||
let clock = wall_clock();
|
||||
assert!(clock == "--:--" || (clock.len() == 5 && clock.as_bytes().get(2) == Some(&b':')));
|
||||
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let mut show = Show::new(&doc, 0, false, true);
|
||||
assert_eq!(show.notes_px, NOTES_DEFAULT);
|
||||
show.nudge_notes(NOTES_STEP);
|
||||
assert_eq!(show.notes_px, 22.0);
|
||||
show.nudge_notes(1000.0);
|
||||
assert_eq!(show.notes_px, NOTES_MAX);
|
||||
show.nudge_notes(-1000.0);
|
||||
assert_eq!(show.notes_px, NOTES_MIN);
|
||||
show.nudge_notes(f32::NAN);
|
||||
assert_eq!(show.notes_px, NOTES_MIN);
|
||||
let v = show.showing_value(1, 16.0 / 9.0, Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), 0.0);
|
||||
assert_eq!(v["presenterPaused"], json!(false));
|
||||
assert_eq!(v["notesPx"], json!(NOTES_MIN));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user