Merge main into test/portable-render-validation

Main fixed the PDF font fixture independently (9005839), so that half
of this branch is dropped in favour of main's version; the language
dialog label fix remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Koch-Sultan
2026-11-01 16:54:25 -05:00
co-authored by Claude Opus 5.5
23 changed files with 1164 additions and 51 deletions
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
Generated
+28 -28
View File
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.13.1"
version = "0.14.1"
[[package]]
name = "deckcraft-opus"
version = "0.13.1"
version = "0.14.1"
[[package]]
name = "deckcraft-pdf"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.13.1"
version = "0.14.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.13.1"
version = "0.14.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+9 -1
View File
@@ -286,7 +286,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+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 -4
View File
@@ -202,10 +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> {
// An installed variable Ubuntu can expose many faces; use the bundled static
// Light face so the damaged fixture always adds exactly one face.
let bundled = FontDb::with_font_dirs(Vec::new());
let mut bytes = bundled.face("Ubuntu", "Light").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() {
+12 -3
View File
@@ -82,9 +82,18 @@ fn is_cj_nonstarter(c: char) -> bool {
)
}
/// A character that continues a Latin-style word: a letter or digit that Unicode does not break
/// between, as opposed to CJK ideographs, kana, spaces, punctuation and symbols such as `&` or `×`
/// (which share the AL and AI line-break classes with letters).
fn is_word_char(c: char) -> bool {
use unicode_linebreak::BreakClass::{Alphabetic, Ambiguous, HebrewLetter, Numeric};
matches!(unicode_linebreak::break_property(c as u32), Alphabetic | Ambiguous | HebrewLetter | Numeric) && c.is_alphanumeric()
}
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
/// Korean breaks only between words.
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/`, `\`, `?`, `!`, `}`
/// or `+` inside a word (measured in PowerPoint 2016 for Windows; before an ideograph or a space
/// the Unicode break stays), and Korean breaks only between words.
pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
@@ -113,7 +122,7 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>,
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) {
false
} else {
latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
latin_break || !matches!(prev, '/' | '\\' | '?' | '!' | '}' | '+') || !is_word_char(next)
};
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)
+21 -3
View File
@@ -857,19 +857,37 @@ fn one_line(text: &str) -> f64 {
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
// where it no longer fits; Unicode line breaking would allow a break after each slash.
for sep in ["/", "\\"] {
// where it no longer fits; Unicode line breaking would allow a break after each slash. The
// same holds after ?, !, } and + (a URL's query, measured in PowerPoint 2016 for Windows).
for sep in ["/", "\\", "?", "!", "}", "+"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
// The line breaks where the next character no longer fits, not at an earlier separator
// (it may still end with one when that is where the width runs out).
let next: String = lines[0].chars().chain(lines[1].chars().take(1)).collect();
assert!(one_line(&next) > w, "no break right after {sep:?} while more fits: {lines:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
assert!(lines[0].ends_with('/'), "{lines:?}");
// A slash followed by a space still breaks, and hyphens still break.
// Outside a Latin word the Unicode break stays: "日本!" fits, "語" moves to the next line.
let lines = wrapped("日本!語", one_line("日本!") + 0.5, |_| {});
assert_eq!(lines[0], "日本!", "{lines:?}");
// Nor before a symbol that only shares the letters' line-break class.
let (allowed, _) = break_opportunities("ab!&cd", false);
assert!(allowed[3], "break after ! before &: {allowed:?}");
// Latin text before CJK still breaks after the mark: "ab?" fits, "日本" moves down.
let lines = wrapped("ab?日本", one_line("ab?") + 0.5, |_| {});
assert_eq!(lines[0], "ab?", "{lines:?}");
// A separator followed by a space still breaks, and hyphens still break.
for sep in ["/", "?", "!"] {
let lines = wrapped(&format!("alpha{sep} beta"), one_line(&format!("alpha{sep} b")) + 0.5, |_| {});
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), [format!("alpha{sep}"), "beta".into()], "{lines:?}");
}
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
assert_eq!(lines[0], "alpha-beta-gamma-");
}
+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));
+2 -1
View File
@@ -1,6 +1,6 @@
# DeckCraft vs Microsoft PowerPoint: parity assessment
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (Animations shortfall: preview from the selected effect is in; previous: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (no break after `?`, `!`, `}`, `+` inside a word; previous: trivial, Animations shortfall: preview from the selected effect is in; before that: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
@@ -289,6 +289,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Animations shortfall: preview from the selected effect (Play From) is in. Percentages unchanged |
| 2026-10-17 | trivial | A word no longer breaks after `?`, `!`, `}` or `+` (a URL's query wraps at the edge as in PowerPoint 2016 for Windows), as it already didn't after `/` and `\`. Headline percentages unchanged |
| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged |
| 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged |
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset |
+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.
+8
View File
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.deckcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
--oci) OCI=1; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
@@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [[ "$OCI" = 1 ]]; then
OUT_OCI="$DIST/$BASENAME-oci"
rm -rf "$OUT_OCI"
flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
echo "wrote OCI layout to $OUT_OCI"
fi
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Build deckcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
# for consumption by Flatpak OCI clients (flatpak-oci-specs).
#
# Usage:
# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
#
# Environment variables:
# REGISTRY Target registry image (default: ghcr.io/storytold/deckcraft)
# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
# GITHUB_TOKEN Token for GHCR authentication when --push is used
set -euo pipefail
# shellcheck source=../common.sh
. "$(dirname "${BASH_SOURCE[0]}")/../common.sh"
HERE="$ROOT/packaging/linux"
PUSH=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--push) PUSH=1; shift ;;
-h | --help) sed -n '2,12p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="deckcraft-$VERSION-linux-$ARCH"
OUT_OCI="$DIST/$BASENAME-oci"
echo "==> Building Flatpak OCI layout for $BASENAME"
"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
REGISTRY="${REGISTRY:-ghcr.io/storytold/deckcraft}"
TAG="${TAG:-$VERSION-$ARCH}"
echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
if [[ "$PUSH" = 1 ]]; then
command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
AUTH_ARGS=()
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
fi
echo "==> Pushing to $REGISTRY:$TAG"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
# Also update architecture-specific stable tag
echo "==> Pushing to $REGISTRY:stable-$ARCH"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
echo "==> Push complete"
fi