mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 22:47:04 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90058395b7 |
@@ -27,4 +27,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const PREFIX: &str = "#deckcraft-presenter=";
|
||||
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
fn origin() -> Result<String, String> {
|
||||
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||
}
|
||||
pub fn token() -> Option<String> {
|
||||
let hash = web_sys::window()?.location().hash().ok()?;
|
||||
let token = hash.strip_prefix(PREFIX)?;
|
||||
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||
}
|
||||
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||
}
|
||||
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err("Presenter message is too large".into());
|
||||
}
|
||||
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||
}
|
||||
struct State {
|
||||
popup: Option<web_sys::Window>,
|
||||
token: String,
|
||||
commands: VecDeque<Command>,
|
||||
ready: bool,
|
||||
refresh_images: bool,
|
||||
}
|
||||
pub struct BrowserHost {
|
||||
state: Rc<RefCell<State>>,
|
||||
origin: String,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||
images: DeltaCache,
|
||||
}
|
||||
impl BrowserHost {
|
||||
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let origin = origin()?;
|
||||
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||
let weak = Rc::downgrade(&state);
|
||||
let expected = origin.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||
let Some(popup) = state.popup.as_ref() else { return };
|
||||
if !same_source(&event, popup, &expected) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||
return;
|
||||
}
|
||||
match value.get("kind").and_then(|v| v.as_str()) {
|
||||
Some("ready") => {
|
||||
state.ready = true;
|
||||
state.refresh_images = true;
|
||||
}
|
||||
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
state.commands.push_back(command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||
}
|
||||
fn remove_button(&mut self) {
|
||||
if let Some((button, callback)) = self.button.take() {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
fn recovery_button(&mut self) {
|
||||
if self.button.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(body) = document.body() else { return };
|
||||
let Ok(element) = document.create_element("button") else { return };
|
||||
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||
button.set_text_content(Some("Open presenter window"));
|
||||
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||
let button_copy = button.clone();
|
||||
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||
if let Some(state) = weak.upgrade() {
|
||||
match open_popup(&state) {
|
||||
Ok(()) => {
|
||||
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||
}
|
||||
Err(_) => {
|
||||
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||
self.button = Some((button, callback));
|
||||
} else {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||
if let Some(popup) = &state.popup
|
||||
&& !popup.closed().unwrap_or(true)
|
||||
{
|
||||
let _ = popup.focus();
|
||||
return Ok(());
|
||||
}
|
||||
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||
url.set_hash(&format!("{PREFIX}{token}"));
|
||||
let popup = window
|
||||
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||
.map_err(error)?
|
||||
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||
if popup.closed().unwrap_or(true) {
|
||||
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||
}
|
||||
state.popup = Some(popup);
|
||||
state.token = token;
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
Ok(())
|
||||
}
|
||||
impl Host for BrowserHost {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.close();
|
||||
let result = open_popup(&self.state);
|
||||
if result.is_err() {
|
||||
self.recovery_button();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
self.state.borrow_mut().commands.drain(..).collect()
|
||||
}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
if !self.is_open() {
|
||||
self.recovery_button();
|
||||
return;
|
||||
}
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if !state.ready {
|
||||
return;
|
||||
}
|
||||
if let Some(popup) = state.popup.clone() {
|
||||
let token = state.token.clone();
|
||||
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||
send(
|
||||
&popup,
|
||||
&self.origin,
|
||||
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||
)
|
||||
});
|
||||
if sent.is_ok() {
|
||||
state.refresh_images = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(window) = state.popup.take() {
|
||||
let _ = window.close();
|
||||
}
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
state.token.clear();
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserHost {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup {
|
||||
token: String,
|
||||
origin: String,
|
||||
opener: web_sys::Window,
|
||||
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||
last_received: Rc<RefCell<f64>>,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
view: View,
|
||||
}
|
||||
impl Popup {
|
||||
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||
// exact source identity and origin before any data is accepted.
|
||||
let opener = window.opener().map_err(error)?;
|
||||
if opener.is_null() || opener.is_undefined() {
|
||||
return Err("The audience window is unavailable".into());
|
||||
}
|
||||
let opener: web_sys::Window = opener.unchecked_into();
|
||||
let origin = origin()?;
|
||||
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||
let received = last_received.clone();
|
||||
let target = snapshot.clone();
|
||||
let expected_origin = origin.clone();
|
||||
let expected_opener = opener.clone();
|
||||
let expected_token = token.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||
if let Some(previous) = target.borrow().as_ref() {
|
||||
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.current = previous.current.clone();
|
||||
}
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
if let Some(document) = window.document() {
|
||||
document.set_title("Presenter View — DeckCraft");
|
||||
}
|
||||
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||
Ok(popup)
|
||||
}
|
||||
}
|
||||
impl eframe::App for Popup {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.snapshot.borrow();
|
||||
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||
if !self.view.prepare(ui.ctx(), notes) {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if self.opener.closed().unwrap_or(true) {
|
||||
ui.heading("The audience window has closed.");
|
||||
return;
|
||||
}
|
||||
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||
ui.heading("The audience window is not responding.");
|
||||
ui.label("Return to the audience window to resume the connection.");
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
for command in self.view.ui(ui, snapshot) {
|
||||
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||
}
|
||||
} else {
|
||||
ui.label("Waiting for the audience window…");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ pub fn start() {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
if let Some(token) = crate::presenter::token() {
|
||||
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
host.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
|
||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||
Err(error) => {
|
||||
log::error!("Presenter window: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Services {
|
||||
presenter,
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -259,6 +259,16 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identity_is_stable_nonzero_and_json_safe() {
|
||||
let id = instance_id();
|
||||
assert_eq!(instance_id(), id);
|
||||
assert!(id != 0 && id <= UID_MASK);
|
||||
assert_eq!(id & 0xFF_FFFF, 0);
|
||||
let encoded = serde_json::to_value(id).unwrap();
|
||||
assert_eq!(encoded.as_f64().unwrap() as u64, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_unsaved_presentations() {
|
||||
let dir = temp("recovery");
|
||||
|
||||
@@ -202,7 +202,8 @@ fn unsafe_url_links_are_not_exported() {
|
||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||
fn font_without_head() -> Vec<u8> {
|
||||
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
|
||||
// The final bundled face is always Ubuntu Light; a system lookup may select a variable font.
|
||||
let mut bytes = deckcraft_fonts::bundled().last().unwrap().to_vec();
|
||||
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
||||
let mut offset = 0;
|
||||
while offset + old.len() <= bytes.len() {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod icons;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod opening;
|
||||
pub mod presenter;
|
||||
pub mod ribbon;
|
||||
pub mod settings;
|
||||
pub mod show;
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Bounded presenter-window messages. The audience owns the show; the remote window
|
||||
//! receives previews and sends input, never a second independently advancing show.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MAX_PREVIEW_SIDE: usize = 640;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preview {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
impl Preview {
|
||||
pub fn from_image(image: &egui::ColorImage) -> Option<Self> {
|
||||
let [w, h] = image.size;
|
||||
if w == 0 || h == 0 || w.checked_mul(h)? != image.pixels.len() {
|
||||
return None;
|
||||
}
|
||||
let scale = (MAX_PREVIEW_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||
let width = ((w as f64 * scale) as usize).max(1);
|
||||
let height = ((h as f64 * scale) as usize).max(1);
|
||||
let mut rgba = Vec::with_capacity(width * height * 4);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = image.pixels.get((y * h / height) * w + x * w / width)?;
|
||||
rgba.extend_from_slice(&pixel.to_srgba_unmultiplied());
|
||||
}
|
||||
}
|
||||
Some(Self { width, height, rgba })
|
||||
}
|
||||
pub fn image(&self) -> Option<egui::ColorImage> {
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| self.width > MAX_PREVIEW_SIDE
|
||||
|| self.height > MAX_PREVIEW_SIDE
|
||||
|| self.width.checked_mul(self.height)?.checked_mul(4)? != self.rgba.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([self.width, self.height], &self.rgba))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub slide: usize,
|
||||
pub total: usize,
|
||||
pub ended: bool,
|
||||
pub notes: String,
|
||||
pub elapsed: String,
|
||||
pub paused: bool,
|
||||
pub notes_px: f32,
|
||||
pub blank: Option<String>,
|
||||
pub grid: bool,
|
||||
pub zoom_armed: bool,
|
||||
pub current: Option<Preview>,
|
||||
pub next: Option<Preview>,
|
||||
}
|
||||
|
||||
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||
#[derive(Default)]
|
||||
pub struct DeltaCache {
|
||||
current: Option<Preview>,
|
||||
next: Option<Preview>,
|
||||
token: String,
|
||||
}
|
||||
impl DeltaCache {
|
||||
pub fn send<E>(
|
||||
&mut self,
|
||||
mut snapshot: Snapshot,
|
||||
token: &str,
|
||||
refresh: bool,
|
||||
send: impl FnOnce(Snapshot, bool, bool) -> Result<(), E>,
|
||||
) -> Result<(), E> {
|
||||
let fresh = refresh || self.token != token;
|
||||
let current_changed = fresh || self.current != snapshot.current;
|
||||
let next_changed = fresh || self.next != snapshot.next;
|
||||
let current = snapshot.current.clone();
|
||||
let next = snapshot.next.clone();
|
||||
if !current_changed {
|
||||
snapshot.current = None;
|
||||
}
|
||||
if !next_changed {
|
||||
snapshot.next = None;
|
||||
}
|
||||
send(snapshot, current_changed, next_changed)?;
|
||||
self.current = current;
|
||||
self.next = next;
|
||||
self.token = token.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Command {
|
||||
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
|
||||
Zoom { x: f32, y: f32 },
|
||||
End,
|
||||
}
|
||||
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
|
||||
pub trait Host {
|
||||
fn open(&mut self) -> Result<(), String>;
|
||||
fn is_open(&self) -> bool;
|
||||
fn commands(&mut self) -> Vec<Command>;
|
||||
fn update(&mut self, snapshot: Snapshot);
|
||||
fn close(&mut self);
|
||||
}
|
||||
|
||||
/// The popup's independent egui renderer. It never advances a local show clock.
|
||||
#[derive(Default)]
|
||||
pub struct View {
|
||||
fonts: crate::uifonts::UiFonts,
|
||||
styled: bool,
|
||||
current: Option<egui::TextureHandle>,
|
||||
next: Option<egui::TextureHandle>,
|
||||
previous_current: Option<Preview>,
|
||||
previous_next: Option<Preview>,
|
||||
}
|
||||
impl View {
|
||||
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||
/// Font definitions take effect on the next pass; never forward input on a discarded pass.
|
||||
pub fn prepare(&mut self, ctx: &egui::Context, notes: &str) -> bool {
|
||||
if !self.styled {
|
||||
crate::theme::install_fonts_with(ctx, &self.fonts.faces);
|
||||
crate::theme::apply(ctx, &crate::theme::Tokens::dark());
|
||||
self.styled = true;
|
||||
ctx.request_discard("install presenter interface fonts before layout");
|
||||
return false;
|
||||
}
|
||||
if ctx.will_discard() {
|
||||
return false;
|
||||
}
|
||||
self.fonts.note(ctx, notes);
|
||||
if self.fonts.apply(ctx) {
|
||||
ctx.request_discard("install presenter note fallback fonts before layout");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot) -> Vec<Command> {
|
||||
if !self.prepare(ui.ctx(), &snapshot.notes) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
let keys = ui.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|event| if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { Some((*key, *modifiers)) } else { None })
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
for (key, modifiers) in keys {
|
||||
// Consume once before widgets see Space/Enter, avoiding a second button activation.
|
||||
ui.input_mut(|i| i.consume_key(modifiers, key));
|
||||
commands.push(Command::Key {
|
||||
name: key.name().into(),
|
||||
command: modifiers.command,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
ctrl: modifiers.ctrl,
|
||||
});
|
||||
}
|
||||
fn update(
|
||||
ctx: &egui::Context,
|
||||
name: &str,
|
||||
previous: &mut Option<Preview>,
|
||||
texture: &mut Option<egui::TextureHandle>,
|
||||
image: &Option<Preview>,
|
||||
) {
|
||||
if previous == image {
|
||||
return;
|
||||
}
|
||||
*previous = image.clone();
|
||||
*texture = image.as_ref().and_then(Preview::image).map(|image| ctx.load_texture(name, image, egui::TextureOptions::LINEAR));
|
||||
}
|
||||
update(ui.ctx(), "remote-current", &mut self.previous_current, &mut self.current, &snapshot.current);
|
||||
update(ui.ctx(), "remote-next", &mut self.previous_next, &mut self.next, &snapshot.next);
|
||||
let key = |name: &str| Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false };
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.heading(&snapshot.elapsed);
|
||||
ui.label(format!("Slide {} of {}", snapshot.slide.saturating_add(1), snapshot.total));
|
||||
for (label, name) in [("◀ Previous", "ArrowLeft"), ("Next ▶", "ArrowRight")] {
|
||||
if ui.button(label).clicked() {
|
||||
commands.push(key(name));
|
||||
}
|
||||
}
|
||||
if ui.button("End Show").clicked() {
|
||||
commands.push(Command::End);
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (label, name) in [
|
||||
(if snapshot.paused { "Resume" } else { "Pause" }, "T"),
|
||||
("Reset", "R"),
|
||||
("Black", "B"),
|
||||
("White", "W"),
|
||||
("All slides", "G"),
|
||||
("Zoom", "Z"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
commands.push(key(name));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.columns(2, |columns| {
|
||||
let left = &mut columns[0];
|
||||
if snapshot.ended {
|
||||
left.heading("End of slide show");
|
||||
} else if let Some(texture) = &self.current {
|
||||
let response = left.add(
|
||||
egui::Image::new(texture)
|
||||
.fit_to_exact_size(fit_preview(texture.size_vec2(), left.available_width(), left.available_height() * 0.8))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if let Some(blank) = &snapshot.blank {
|
||||
let color = if blank == "white" { egui::Color32::WHITE } else { egui::Color32::BLACK };
|
||||
left.painter().rect_filled(response.rect, 0.0, color);
|
||||
}
|
||||
if snapshot.zoom_armed
|
||||
&& response.clicked()
|
||||
&& let Some(point) = response.interact_pointer_pos()
|
||||
{
|
||||
commands.push(Command::Zoom {
|
||||
x: (point.x - response.rect.left()) / response.rect.width().max(1.0),
|
||||
y: (point.y - response.rect.top()) / response.rect.height().max(1.0),
|
||||
});
|
||||
}
|
||||
}
|
||||
if snapshot.zoom_armed {
|
||||
left.label("Click the preview to zoom.");
|
||||
}
|
||||
if snapshot.grid {
|
||||
left.label("Choose a slide in the audience window, or press G to return.");
|
||||
}
|
||||
let right = &mut columns[1];
|
||||
right.label("Next");
|
||||
if let Some(texture) = &self.next {
|
||||
right.add(egui::Image::new(texture).fit_to_exact_size(fit_preview(
|
||||
texture.size_vec2(),
|
||||
right.available_width(),
|
||||
right.available_height() * 0.45,
|
||||
)));
|
||||
} else {
|
||||
right.label("End of slide show");
|
||||
}
|
||||
right.horizontal(|ui| {
|
||||
ui.label("Notes");
|
||||
if ui.button("A−").clicked() {
|
||||
commands.push(key(egui::Key::OpenBracket.name()));
|
||||
}
|
||||
if ui.button("A+").clicked() {
|
||||
commands.push(key(egui::Key::CloseBracket.name()));
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().show(right, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(if snapshot.notes.is_empty() { "No notes." } else { &snapshot.notes })
|
||||
.size(snapshot.notes_px.clamp(10.0, 72.0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
commands
|
||||
}
|
||||
}
|
||||
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
|
||||
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snapshot() -> Snapshot {
|
||||
Snapshot {
|
||||
slide: 0,
|
||||
total: 2,
|
||||
ended: false,
|
||||
notes: String::new(),
|
||||
elapsed: "00:00:00".into(),
|
||||
paused: false,
|
||||
notes_px: 18.0,
|
||||
blank: None,
|
||||
grid: false,
|
||||
zoom_armed: false,
|
||||
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
|
||||
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transport_retries_images_and_only_success_deduplicates() {
|
||||
let mut cache = DeltaCache::default();
|
||||
let first = snapshot();
|
||||
let failed = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && next);
|
||||
assert_eq!(delta.current, first.current);
|
||||
assert_eq!(delta.next, first.next);
|
||||
Err("serialization or postMessage failed")
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && next, "a failed initial send must not suppress either image");
|
||||
assert_eq!(delta.current, first.current);
|
||||
assert_eq!(delta.next, first.next);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(!current && !next);
|
||||
assert!(delta.current.is_none() && delta.next.is_none());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let mut changed = first.clone();
|
||||
changed.current.as_mut().unwrap().rgba = vec![42; 4];
|
||||
let failed = cache.send(changed.clone(), "session", false, |_, current, next| {
|
||||
assert!(current && !next);
|
||||
Err(())
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(changed.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && !next, "unchanged retry after a failed changed-image send");
|
||||
assert_eq!(delta.current, changed.current);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_refreshes_new_sessions_reloads_and_clears_previews() {
|
||||
let mut cache = DeltaCache::default();
|
||||
for (token, refresh) in [("first", false), ("second", false), ("second", true)] {
|
||||
let sent = cache.send(snapshot(), token, refresh, |delta, current, next| {
|
||||
assert!(current && next);
|
||||
assert!(delta.current.is_some() && delta.next.is_some());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
let failed = cache.send(snapshot(), "second", true, |_, current, next| {
|
||||
assert!(current && next);
|
||||
Err(())
|
||||
});
|
||||
// BrowserHost retains refresh_images after failure, even when the token is unchanged.
|
||||
let refresh_images = failed.is_err();
|
||||
let sent = cache.send(snapshot(), "second", refresh_images, |_, current, next| {
|
||||
assert!(current && next, "reload refresh must survive a failed send");
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let mut cleared = snapshot();
|
||||
cleared.current = None;
|
||||
cleared.next = None;
|
||||
let failed = cache.send(cleared.clone(), "second", false, |_, current, next| {
|
||||
assert!(current && next);
|
||||
Err(())
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(cleared.clone(), "second", false, |delta, current, next| {
|
||||
assert!(current && next, "clearing images is retried too");
|
||||
assert!(delta.current.is_none() && delta.next.is_none());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let sent = cache.send(cleared, "second", false, |_, current, next| {
|
||||
assert!(!current && !next);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_installs_interface_fonts_before_non_latin_note_layout() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut view = View::default();
|
||||
let mut data = snapshot();
|
||||
let arabic = "\u{645}\u{631}\u{62d}\u{628}\u{627}";
|
||||
let japanese = "\u{65e5}\u{672c}\u{8a9e}";
|
||||
let korean = "\u{d55c}\u{ae00}";
|
||||
data.notes = format!("{arabic} {japanese} {korean}");
|
||||
let mut installing = 0;
|
||||
let mut layouts = 0;
|
||||
for _ in 0..4 {
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
if !view.prepare(ui.ctx(), &data.notes) {
|
||||
installing += 1;
|
||||
assert!(view.ui(ui, &data).is_empty(), "no commands during a discarded font-install pass");
|
||||
return;
|
||||
}
|
||||
assert!(!ui.ctx().will_discard());
|
||||
layouts += 1;
|
||||
for (family, text) in [("Noto Sans Arabic", arabic), ("BIZ UDPGothic", japanese), ("NanumGothic", korean)] {
|
||||
if deckcraft_fonts::CRAFT_FONTS.iter().any(|face| face.family == family) {
|
||||
ui.ctx().fonts_mut(|fonts| {
|
||||
for c in text.chars() {
|
||||
assert!(fonts.has_glyph(&egui::FontId::proportional(18.0), c), "missing bundled {family} glyph {c:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = view.ui(ui, &data);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
assert!(installing > 0 && layouts > 0, "font installation must precede an actual notes layout");
|
||||
}
|
||||
#[test]
|
||||
fn popup_input_is_forwarded_once_without_advancing_its_own_state() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let snapshot = Snapshot {
|
||||
slide: 0,
|
||||
total: 3,
|
||||
ended: false,
|
||||
notes: "Synthetic notes".into(),
|
||||
elapsed: "00:00:00".into(),
|
||||
paused: false,
|
||||
notes_px: 18.0,
|
||||
blank: None,
|
||||
grid: false,
|
||||
zoom_armed: false,
|
||||
current: None,
|
||||
next: None,
|
||||
};
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
|
||||
sent.extend(view.ui(ui, snapshot));
|
||||
},
|
||||
(View::default(), snapshot, Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Next ▶").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2.len(), 1);
|
||||
h.key_press(egui::Key::Space);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2.len(), 2, "Space must not also activate the focused Next button");
|
||||
assert_eq!(h.state().0.previous_current, None);
|
||||
assert_eq!(h.state().1.slide, 0, "only an audience snapshot changes the displayed position");
|
||||
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
|
||||
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
|
||||
let p = Preview::from_image(&image).unwrap();
|
||||
assert_eq!((p.width, p.height), (640, 360));
|
||||
assert_eq!(p.image().unwrap().pixels[0], egui::Color32::RED);
|
||||
for (width, height, rgba) in [(usize::MAX, 1, vec![]), (0, 1, vec![]), (1, 1, vec![0; 3]), (641, 1, vec![0; 2564])] {
|
||||
assert!(Preview { width, height, rgba }.image().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<InboxEvent>>>;
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Browser-owned presenter popup; absent on native hosts.
|
||||
pub presenter: Option<Box<dyn crate::presenter::Host>>,
|
||||
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
|
||||
pub pick_open: Option<PickFn>,
|
||||
/// Save dialog with a suggested name → path.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub struct Show {
|
||||
/// Preview renders started on another thread.
|
||||
#[cfg(test)]
|
||||
preview_spawns: usize,
|
||||
remote_preview: Option<crate::presenter::Preview>,
|
||||
remote_next: Option<(usize, crate::presenter::Preview)>,
|
||||
remote_sent: f64,
|
||||
remote_opened: bool,
|
||||
/// The next slide's first frame, rendered in the background while this one is shown so
|
||||
/// advancing only uploads it (#240).
|
||||
prefetch: Option<Prefetch>,
|
||||
@@ -231,6 +235,10 @@ impl Show {
|
||||
next_job: None,
|
||||
#[cfg(test)]
|
||||
preview_spawns: 0,
|
||||
remote_preview: None,
|
||||
remote_next: None,
|
||||
remote_sent: f64::NEG_INFINITY,
|
||||
remote_opened: false,
|
||||
prefetch: None,
|
||||
backdrop: None,
|
||||
blank: None,
|
||||
|
||||
@@ -12,6 +12,22 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
|
||||
.collect()
|
||||
});
|
||||
let (keep, consumed) = handle_keys(show, doc, now, keys, presenter);
|
||||
for (key, modifiers) in consumed {
|
||||
ctx.input_mut(|i| i.consume_key(modifiers, key));
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
pub(super) fn handle_keys(
|
||||
show: &mut Show,
|
||||
doc: &Presentation,
|
||||
now: f64,
|
||||
keys: Vec<(egui::Key, egui::Modifiers)>,
|
||||
presenter: bool,
|
||||
) -> (bool, Vec<(egui::Key, egui::Modifiers)>) {
|
||||
let mut consumed = vec![];
|
||||
let mut keep = true;
|
||||
let mut actions = vec![];
|
||||
for (k, m) in keys {
|
||||
@@ -31,15 +47,14 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
||||
}
|
||||
// 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);
|
||||
consumed.push((k, m));
|
||||
}
|
||||
for action in actions {
|
||||
if !show.apply(action, doc, now) {
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
keep
|
||||
(keep, consumed)
|
||||
}
|
||||
|
||||
/// Whether the grid that the presenter key G opens is showing in this viewport.
|
||||
|
||||
@@ -1123,6 +1123,66 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() {
|
||||
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
use crate::presenter::{Command, Host, Snapshot};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
#[derive(Default)]
|
||||
struct Bridge {
|
||||
opens: usize,
|
||||
closes: usize,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
struct Mock(Rc<RefCell<Bridge>>);
|
||||
impl Host for Mock {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.0.borrow_mut().opens += 1;
|
||||
Ok(())
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
std::mem::take(&mut self.0.borrow_mut().commands)
|
||||
}
|
||||
fn update(&mut self, _: Snapshot) {}
|
||||
fn close(&mut self) {
|
||||
self.0.borrow_mut().closes += 1;
|
||||
}
|
||||
}
|
||||
let bridge = Rc::new(RefCell::new(Bridge::default()));
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let services = crate::Services { presenter: Some(Box::new(Mock(bridge.clone()))), ..Default::default() };
|
||||
let mut app = SlideApp::new(session, services);
|
||||
app.ui.presenter_view = true;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.state_mut().start_show(0, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bridge.borrow().opens, 1);
|
||||
for (key, slide) in [(egui::Key::ArrowRight, 1), (egui::Key::ArrowLeft, 0), (egui::Key::End, 2), (egui::Key::Home, 0)] {
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: key.name().into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
|
||||
}
|
||||
bridge.borrow_mut().commands.push(Command::End);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.is_none());
|
||||
assert_eq!(bridge.borrow().closes, 1);
|
||||
h.state_mut().start_show(1, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bridge.borrow().opens, 2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, 1);
|
||||
}
|
||||
|
||||
/// #964: the next-slide preview renders on another thread; the presenter frame that asks for
|
||||
/// it doesn't wait, and the preview arrives on a later frame.
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::keys::keyboard;
|
||||
use super::keys::{handle_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::presenter::{PresenterTimer, format_elapsed, 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,
|
||||
@@ -58,6 +58,9 @@ 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;
|
||||
if let Some(host) = app.services.presenter.as_mut() {
|
||||
host.close();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let now = ctx.input(|i| i.time);
|
||||
@@ -71,8 +74,35 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
show.observe_visible_frame(now, web_time::Instant::now());
|
||||
begin_show(&ctx, &mut show, &doc, now);
|
||||
begin_show(&ctx, &mut show, &doc, now, app.services.presenter.is_some());
|
||||
let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] };
|
||||
if show.presenter
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if !show.remote_opened {
|
||||
show.remote_opened = true;
|
||||
if let Err(message) = host.open() {
|
||||
app.status = Some((message, crate::now_ms()));
|
||||
}
|
||||
}
|
||||
for command in host.commands() {
|
||||
match command {
|
||||
crate::presenter::Command::End => out.keep = false,
|
||||
crate::presenter::Command::Zoom { x, y } if x.is_finite() && y.is_finite() && show.zoom_arm => {
|
||||
show.zoom = Some(Zoom::at(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
show.zoom_arm = false;
|
||||
}
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
|
||||
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -94,6 +124,43 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
draw_pen_and_hints(ui, &mut show, &f, &painter, pointing);
|
||||
draw_bars(ui, &mut show, &f, &painter, &mut out);
|
||||
draw_extras(&mut show, &f, &painter, &mut out);
|
||||
if out.keep
|
||||
&& show.presenter
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if now - show.remote_sent >= 0.2 {
|
||||
let next = show.state.peek_next(&doc);
|
||||
if let Some(n) = next
|
||||
&& show.remote_next.as_ref().is_none_or(|(old, _)| *old != n)
|
||||
{
|
||||
let 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), 640);
|
||||
let image = deckcraft_render::render_slide(
|
||||
&doc,
|
||||
n,
|
||||
&RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() },
|
||||
);
|
||||
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
|
||||
}
|
||||
host.update(crate::presenter::Snapshot {
|
||||
slide: show.state.slide,
|
||||
total: doc.slides.len(),
|
||||
ended: show.ended,
|
||||
notes: doc.slides.get(show.state.slide).map(|s| s.notes_text()).unwrap_or_default(),
|
||||
elapsed: format_elapsed(show.timer.elapsed(now)),
|
||||
paused: show.timer.paused(),
|
||||
notes_px: show.notes_px,
|
||||
blank: show.blank_name().map(str::to_owned),
|
||||
grid: show.grid,
|
||||
zoom_armed: show.zoom_arm,
|
||||
current: show.remote_preview.clone(),
|
||||
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
|
||||
});
|
||||
show.remote_sent = now;
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
finish(app, show, &f, out.keep);
|
||||
}
|
||||
|
||||
@@ -110,7 +177,7 @@ fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) {
|
||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, remote: bool) {
|
||||
if !show.started {
|
||||
show.started = true;
|
||||
show.slide_start = now;
|
||||
@@ -121,7 +188,7 @@ fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64
|
||||
show.show_start = now;
|
||||
show.timer = PresenterTimer::start(now);
|
||||
show.last_move = now;
|
||||
if show_fills_screen(show.reading, &doc.show.show_type) {
|
||||
if show_fills_screen(show.reading, &doc.show.show_type) && !(show.presenter && remote) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
|
||||
}
|
||||
show.after_enter(doc, now);
|
||||
@@ -303,6 +370,9 @@ fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option<TextureHandle> {
|
||||
render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref())
|
||||
}
|
||||
};
|
||||
if show.remote_opened && show.presenter {
|
||||
show.remote_preview = crate::presenter::Preview::from_image(&ci);
|
||||
}
|
||||
let tex = match show.tex.take() {
|
||||
Some((_, mut h)) => {
|
||||
crate::textures::replace(ctx, &mut h, ci);
|
||||
@@ -730,7 +800,7 @@ fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out)
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// Presenter view in a second window.
|
||||
if out.keep && show.presenter && !show.reading {
|
||||
if out.keep && show.presenter && !show.reading && !show.remote_opened {
|
||||
presenter_window(ctx, show, doc, now, &mut out.keep);
|
||||
}
|
||||
}
|
||||
@@ -740,6 +810,9 @@ fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) {
|
||||
if keep {
|
||||
app.show = Some(show);
|
||||
} else {
|
||||
if let Some(host) = app.services.presenter.as_mut() {
|
||||
host.close();
|
||||
}
|
||||
app.media.stop_owned(crate::media::Owner::Show);
|
||||
if !show.reading {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# WASM presenter-view popout (#729)
|
||||
|
||||
Tracks https://github.com/storytold/deckcraft/issues/729 (the reporter moved duplicate #726 here).
|
||||
|
||||
## Implementation
|
||||
|
||||
The browser presenter opens the same DeckCraft web build in a separate, same-origin window.
|
||||
That window starts its own eframe runner and renders only presenter controls, current/next
|
||||
previews and notes. The audience window remains the sole owner of slideshow state, animations,
|
||||
media and navigation. Presenter keyboard input uses the same navigation handler as the native
|
||||
presenter, so an input is applied once rather than advancing two independent shows.
|
||||
|
||||
Messages check the exact origin, source window and a per-opening session identifier. Input
|
||||
queues and preview dimensions are bounded. Previews use the audience's rendered current frame;
|
||||
unchanged preview pixels are not retransmitted. Presenter updates are limited to five per second.
|
||||
|
||||
If the browser blocks the initial popup, an explicit Open presenter window button calls the
|
||||
browser API directly from its click handler. Browser policy can still require the user to allow
|
||||
popups, or choose to open a tab instead of a window. Closing the presenter leaves the audience
|
||||
running and offers the same button to reopen it. Ending the show closes the presenter. The
|
||||
presenter reports a closed or unresponsive audience rather than continuing an independent show.
|
||||
Native presenter view continues to use its existing egui viewport.
|
||||
|
||||
No native second-display placement, Linux display-counting policy, laser/rehearsal changes or
|
||||
other contributors' unmerged presenter work is included.
|
||||
|
||||
## Validation
|
||||
|
||||
Candidate implementation; not yet qualified for release. The current-main reconciliation and
|
||||
transport/font corrections have had source inspection and static diff checks only. No local Rust
|
||||
builds, tests or formatter were run for this candidate; hosted compilation and regression tests
|
||||
remain pending. Earlier-head validation recorded in the PR does not qualify this changed tree.
|
||||
The original bug was reproduced on the deployed 0.7.0 browser build on 2026-10-14; that
|
||||
deployment's exact commit is unknown.
|
||||
|
||||
Regression coverage submitted for hosted execution checks successful image deduplication,
|
||||
failed-send retries, session/reload refresh and clearing previews. The independent popup uses
|
||||
the existing interface font definitions and UiFonts fallback discovery before laying out notes,
|
||||
discarding font-install passes before input forwarding. Its font regression checks bundled
|
||||
Arabic/Japanese/Hangul glyphs when those optional font inputs are present; missing optional
|
||||
inputs do not establish script coverage.
|
||||
|
||||
Source inspection verifies these lifecycle paths: a blocked popup offers an explicit retry
|
||||
whose click handler calls the browser API; closing the popup offers reopening; ending the show
|
||||
closes the popup; incoming messages check origin, source window and opening-session token.
|
||||
This establishes the intended control flow, not empirical compatibility with each browser.
|
||||
|
||||
Runtime checks that would add empirical coverage with a synthetic deck:
|
||||
|
||||
- Popup allowed and blocked, explicit retry, and independent window positioning.
|
||||
- Initial popup behavior in Safari and Firefox, including whether explicit retry is needed on
|
||||
each show start. These browser families have not been exercised for this candidate; missing
|
||||
manual coverage alone is not evidence of a defect or a mandatory merge blocker.
|
||||
- Next/Previous, Home/End, numeric navigation, and no double advance on Space/Enter.
|
||||
- Current/next previews, notes, pause/reset, black/white screens, All Slides and zoom.
|
||||
- Navigation from both windows, including animation steps and the end screen.
|
||||
- Close/reopen the presenter, reload it, end/restart the show, and close/reload the audience.
|
||||
- Rejection of messages from the wrong origin, source window or prior session.
|
||||
- Native presenter regressions, WASM compilation and the full repository quality gates.
|
||||
|
||||
All runtime items above, including actual browser popup/reload/end-show execution, remain
|
||||
unexecuted for this candidate. Shared native tests and source inspection do not constitute
|
||||
browser-executed validation. Font subsets can legitimately omit individual script families;
|
||||
the regression always checks installation/layout/discard handling, and checks a script's glyphs
|
||||
only when its corresponding bundled face is present.
|
||||
Reference in New Issue
Block a user