Compare commits

...
1 Commits
Author SHA1 Message Date
FredAppsandFredApps 90058395b7 WASM: open presenter view in a separate browser window (#729) (#730)
* docs: track WASM presenter popout work (#729)

* Fix presenter transport retries and initialize popup note fonts

* Clarify optional font coverage and source-verified popup lifecycle

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-01 21:36:45 +01:00
14 changed files with 1018 additions and 11 deletions
+1 -1
View File
@@ -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"] }
+2
View File
@@ -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;
+303
View File
@@ -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());
}
}
}
+17
View File
@@ -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();
+10
View File
@@ -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");
+2 -1
View File
@@ -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() {
+1
View File
@@ -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;
+450
View File
@@ -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());
}
}
}
+2
View File
@@ -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.
+8
View File
@@ -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,
+18 -3
View File
@@ -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.
+60
View File
@@ -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]
+79 -6
View File
@@ -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));
+65
View File
@@ -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.