mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-02 06:57:04 -06:00
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:
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
Generated
+28
-28
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -27,4 +27,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const PREFIX: &str = "#deckcraft-presenter=";
|
||||
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
fn origin() -> Result<String, String> {
|
||||
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||
}
|
||||
pub fn token() -> Option<String> {
|
||||
let hash = web_sys::window()?.location().hash().ok()?;
|
||||
let token = hash.strip_prefix(PREFIX)?;
|
||||
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||
}
|
||||
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||
}
|
||||
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err("Presenter message is too large".into());
|
||||
}
|
||||
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||
}
|
||||
struct State {
|
||||
popup: Option<web_sys::Window>,
|
||||
token: String,
|
||||
commands: VecDeque<Command>,
|
||||
ready: bool,
|
||||
refresh_images: bool,
|
||||
}
|
||||
pub struct BrowserHost {
|
||||
state: Rc<RefCell<State>>,
|
||||
origin: String,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||
images: DeltaCache,
|
||||
}
|
||||
impl BrowserHost {
|
||||
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let origin = origin()?;
|
||||
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||
let weak = Rc::downgrade(&state);
|
||||
let expected = origin.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||
let Some(popup) = state.popup.as_ref() else { return };
|
||||
if !same_source(&event, popup, &expected) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||
return;
|
||||
}
|
||||
match value.get("kind").and_then(|v| v.as_str()) {
|
||||
Some("ready") => {
|
||||
state.ready = true;
|
||||
state.refresh_images = true;
|
||||
}
|
||||
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
state.commands.push_back(command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||
}
|
||||
fn remove_button(&mut self) {
|
||||
if let Some((button, callback)) = self.button.take() {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
fn recovery_button(&mut self) {
|
||||
if self.button.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(body) = document.body() else { return };
|
||||
let Ok(element) = document.create_element("button") else { return };
|
||||
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||
button.set_text_content(Some("Open presenter window"));
|
||||
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||
let button_copy = button.clone();
|
||||
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||
if let Some(state) = weak.upgrade() {
|
||||
match open_popup(&state) {
|
||||
Ok(()) => {
|
||||
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||
}
|
||||
Err(_) => {
|
||||
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||
self.button = Some((button, callback));
|
||||
} else {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||
if let Some(popup) = &state.popup
|
||||
&& !popup.closed().unwrap_or(true)
|
||||
{
|
||||
let _ = popup.focus();
|
||||
return Ok(());
|
||||
}
|
||||
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||
url.set_hash(&format!("{PREFIX}{token}"));
|
||||
let popup = window
|
||||
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||
.map_err(error)?
|
||||
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||
if popup.closed().unwrap_or(true) {
|
||||
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||
}
|
||||
state.popup = Some(popup);
|
||||
state.token = token;
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
Ok(())
|
||||
}
|
||||
impl Host for BrowserHost {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.close();
|
||||
let result = open_popup(&self.state);
|
||||
if result.is_err() {
|
||||
self.recovery_button();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
self.state.borrow_mut().commands.drain(..).collect()
|
||||
}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
if !self.is_open() {
|
||||
self.recovery_button();
|
||||
return;
|
||||
}
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if !state.ready {
|
||||
return;
|
||||
}
|
||||
if let Some(popup) = state.popup.clone() {
|
||||
let token = state.token.clone();
|
||||
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||
send(
|
||||
&popup,
|
||||
&self.origin,
|
||||
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||
)
|
||||
});
|
||||
if sent.is_ok() {
|
||||
state.refresh_images = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(window) = state.popup.take() {
|
||||
let _ = window.close();
|
||||
}
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
state.token.clear();
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserHost {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup {
|
||||
token: String,
|
||||
origin: String,
|
||||
opener: web_sys::Window,
|
||||
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||
last_received: Rc<RefCell<f64>>,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
view: View,
|
||||
}
|
||||
impl Popup {
|
||||
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||
// exact source identity and origin before any data is accepted.
|
||||
let opener = window.opener().map_err(error)?;
|
||||
if opener.is_null() || opener.is_undefined() {
|
||||
return Err("The audience window is unavailable".into());
|
||||
}
|
||||
let opener: web_sys::Window = opener.unchecked_into();
|
||||
let origin = origin()?;
|
||||
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||
let received = last_received.clone();
|
||||
let target = snapshot.clone();
|
||||
let expected_origin = origin.clone();
|
||||
let expected_opener = opener.clone();
|
||||
let expected_token = token.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||
if let Some(previous) = target.borrow().as_ref() {
|
||||
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.current = previous.current.clone();
|
||||
}
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
if let Some(document) = window.document() {
|
||||
document.set_title("Presenter View — DeckCraft");
|
||||
}
|
||||
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||
Ok(popup)
|
||||
}
|
||||
}
|
||||
impl eframe::App for Popup {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.snapshot.borrow();
|
||||
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||
if !self.view.prepare(ui.ctx(), notes) {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if self.opener.closed().unwrap_or(true) {
|
||||
ui.heading("The audience window has closed.");
|
||||
return;
|
||||
}
|
||||
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||
ui.heading("The audience window is not responding.");
|
||||
ui.label("Return to the audience window to resume the connection.");
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
for command in self.view.ui(ui, snapshot) {
|
||||
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||
}
|
||||
} else {
|
||||
ui.label("Waiting for the audience window…");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ pub fn start() {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
if let Some(token) = crate::presenter::token() {
|
||||
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
host.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
|
||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||
Err(error) => {
|
||||
log::error!("Presenter window: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Services {
|
||||
presenter,
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -259,6 +259,16 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identity_is_stable_nonzero_and_json_safe() {
|
||||
let id = instance_id();
|
||||
assert_eq!(instance_id(), id);
|
||||
assert!(id != 0 && id <= UID_MASK);
|
||||
assert_eq!(id & 0xFF_FFFF, 0);
|
||||
let encoded = serde_json::to_value(id).unwrap();
|
||||
assert_eq!(encoded.as_f64().unwrap() as u64, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_unsaved_presentations() {
|
||||
let dir = temp("recovery");
|
||||
|
||||
@@ -202,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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-");
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod icons;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod opening;
|
||||
pub mod presenter;
|
||||
pub mod ribbon;
|
||||
pub mod settings;
|
||||
pub mod show;
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Bounded presenter-window messages. The audience owns the show; the remote window
|
||||
//! receives previews and sends input, never a second independently advancing show.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MAX_PREVIEW_SIDE: usize = 640;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preview {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
impl Preview {
|
||||
pub fn from_image(image: &egui::ColorImage) -> Option<Self> {
|
||||
let [w, h] = image.size;
|
||||
if w == 0 || h == 0 || w.checked_mul(h)? != image.pixels.len() {
|
||||
return None;
|
||||
}
|
||||
let scale = (MAX_PREVIEW_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||
let width = ((w as f64 * scale) as usize).max(1);
|
||||
let height = ((h as f64 * scale) as usize).max(1);
|
||||
let mut rgba = Vec::with_capacity(width * height * 4);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = image.pixels.get((y * h / height) * w + x * w / width)?;
|
||||
rgba.extend_from_slice(&pixel.to_srgba_unmultiplied());
|
||||
}
|
||||
}
|
||||
Some(Self { width, height, rgba })
|
||||
}
|
||||
pub fn image(&self) -> Option<egui::ColorImage> {
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| self.width > MAX_PREVIEW_SIDE
|
||||
|| self.height > MAX_PREVIEW_SIDE
|
||||
|| self.width.checked_mul(self.height)?.checked_mul(4)? != self.rgba.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([self.width, self.height], &self.rgba))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub slide: usize,
|
||||
pub total: usize,
|
||||
pub ended: bool,
|
||||
pub notes: String,
|
||||
pub elapsed: String,
|
||||
pub paused: bool,
|
||||
pub notes_px: f32,
|
||||
pub blank: Option<String>,
|
||||
pub grid: bool,
|
||||
pub zoom_armed: bool,
|
||||
pub current: Option<Preview>,
|
||||
pub next: Option<Preview>,
|
||||
}
|
||||
|
||||
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||
#[derive(Default)]
|
||||
pub struct DeltaCache {
|
||||
current: Option<Preview>,
|
||||
next: Option<Preview>,
|
||||
token: String,
|
||||
}
|
||||
impl DeltaCache {
|
||||
pub fn send<E>(
|
||||
&mut self,
|
||||
mut snapshot: Snapshot,
|
||||
token: &str,
|
||||
refresh: bool,
|
||||
send: impl FnOnce(Snapshot, bool, bool) -> Result<(), E>,
|
||||
) -> Result<(), E> {
|
||||
let fresh = refresh || self.token != token;
|
||||
let current_changed = fresh || self.current != snapshot.current;
|
||||
let next_changed = fresh || self.next != snapshot.next;
|
||||
let current = snapshot.current.clone();
|
||||
let next = snapshot.next.clone();
|
||||
if !current_changed {
|
||||
snapshot.current = None;
|
||||
}
|
||||
if !next_changed {
|
||||
snapshot.next = None;
|
||||
}
|
||||
send(snapshot, current_changed, next_changed)?;
|
||||
self.current = current;
|
||||
self.next = next;
|
||||
self.token = token.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Command {
|
||||
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
|
||||
Zoom { x: f32, y: f32 },
|
||||
End,
|
||||
}
|
||||
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
|
||||
pub trait Host {
|
||||
fn open(&mut self) -> Result<(), String>;
|
||||
fn is_open(&self) -> bool;
|
||||
fn commands(&mut self) -> Vec<Command>;
|
||||
fn update(&mut self, snapshot: Snapshot);
|
||||
fn close(&mut self);
|
||||
}
|
||||
|
||||
/// The popup's independent egui renderer. It never advances a local show clock.
|
||||
#[derive(Default)]
|
||||
pub struct View {
|
||||
fonts: crate::uifonts::UiFonts,
|
||||
styled: bool,
|
||||
current: Option<egui::TextureHandle>,
|
||||
next: Option<egui::TextureHandle>,
|
||||
previous_current: Option<Preview>,
|
||||
previous_next: Option<Preview>,
|
||||
}
|
||||
impl View {
|
||||
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||
/// Font definitions take effect on the next pass; never forward input on a discarded pass.
|
||||
pub fn prepare(&mut self, ctx: &egui::Context, notes: &str) -> bool {
|
||||
if !self.styled {
|
||||
crate::theme::install_fonts_with(ctx, &self.fonts.faces);
|
||||
crate::theme::apply(ctx, &crate::theme::Tokens::dark());
|
||||
self.styled = true;
|
||||
ctx.request_discard("install presenter interface fonts before layout");
|
||||
return false;
|
||||
}
|
||||
if ctx.will_discard() {
|
||||
return false;
|
||||
}
|
||||
self.fonts.note(ctx, notes);
|
||||
if self.fonts.apply(ctx) {
|
||||
ctx.request_discard("install presenter note fallback fonts before layout");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot) -> Vec<Command> {
|
||||
if !self.prepare(ui.ctx(), &snapshot.notes) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
let keys = ui.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|event| if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { Some((*key, *modifiers)) } else { None })
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
for (key, modifiers) in keys {
|
||||
// Consume once before widgets see Space/Enter, avoiding a second button activation.
|
||||
ui.input_mut(|i| i.consume_key(modifiers, key));
|
||||
commands.push(Command::Key {
|
||||
name: key.name().into(),
|
||||
command: modifiers.command,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
ctrl: modifiers.ctrl,
|
||||
});
|
||||
}
|
||||
fn update(
|
||||
ctx: &egui::Context,
|
||||
name: &str,
|
||||
previous: &mut Option<Preview>,
|
||||
texture: &mut Option<egui::TextureHandle>,
|
||||
image: &Option<Preview>,
|
||||
) {
|
||||
if previous == image {
|
||||
return;
|
||||
}
|
||||
*previous = image.clone();
|
||||
*texture = image.as_ref().and_then(Preview::image).map(|image| ctx.load_texture(name, image, egui::TextureOptions::LINEAR));
|
||||
}
|
||||
update(ui.ctx(), "remote-current", &mut self.previous_current, &mut self.current, &snapshot.current);
|
||||
update(ui.ctx(), "remote-next", &mut self.previous_next, &mut self.next, &snapshot.next);
|
||||
let key = |name: &str| Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false };
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.heading(&snapshot.elapsed);
|
||||
ui.label(format!("Slide {} of {}", snapshot.slide.saturating_add(1), snapshot.total));
|
||||
for (label, name) in [("◀ Previous", "ArrowLeft"), ("Next ▶", "ArrowRight")] {
|
||||
if ui.button(label).clicked() {
|
||||
commands.push(key(name));
|
||||
}
|
||||
}
|
||||
if ui.button("End Show").clicked() {
|
||||
commands.push(Command::End);
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (label, name) in [
|
||||
(if snapshot.paused { "Resume" } else { "Pause" }, "T"),
|
||||
("Reset", "R"),
|
||||
("Black", "B"),
|
||||
("White", "W"),
|
||||
("All slides", "G"),
|
||||
("Zoom", "Z"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
commands.push(key(name));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.columns(2, |columns| {
|
||||
let left = &mut columns[0];
|
||||
if snapshot.ended {
|
||||
left.heading("End of slide show");
|
||||
} else if let Some(texture) = &self.current {
|
||||
let response = left.add(
|
||||
egui::Image::new(texture)
|
||||
.fit_to_exact_size(fit_preview(texture.size_vec2(), left.available_width(), left.available_height() * 0.8))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if let Some(blank) = &snapshot.blank {
|
||||
let color = if blank == "white" { egui::Color32::WHITE } else { egui::Color32::BLACK };
|
||||
left.painter().rect_filled(response.rect, 0.0, color);
|
||||
}
|
||||
if snapshot.zoom_armed
|
||||
&& response.clicked()
|
||||
&& let Some(point) = response.interact_pointer_pos()
|
||||
{
|
||||
commands.push(Command::Zoom {
|
||||
x: (point.x - response.rect.left()) / response.rect.width().max(1.0),
|
||||
y: (point.y - response.rect.top()) / response.rect.height().max(1.0),
|
||||
});
|
||||
}
|
||||
}
|
||||
if snapshot.zoom_armed {
|
||||
left.label("Click the preview to zoom.");
|
||||
}
|
||||
if snapshot.grid {
|
||||
left.label("Choose a slide in the audience window, or press G to return.");
|
||||
}
|
||||
let right = &mut columns[1];
|
||||
right.label("Next");
|
||||
if let Some(texture) = &self.next {
|
||||
right.add(egui::Image::new(texture).fit_to_exact_size(fit_preview(
|
||||
texture.size_vec2(),
|
||||
right.available_width(),
|
||||
right.available_height() * 0.45,
|
||||
)));
|
||||
} else {
|
||||
right.label("End of slide show");
|
||||
}
|
||||
right.horizontal(|ui| {
|
||||
ui.label("Notes");
|
||||
if ui.button("A−").clicked() {
|
||||
commands.push(key(egui::Key::OpenBracket.name()));
|
||||
}
|
||||
if ui.button("A+").clicked() {
|
||||
commands.push(key(egui::Key::CloseBracket.name()));
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().show(right, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(if snapshot.notes.is_empty() { "No notes." } else { &snapshot.notes })
|
||||
.size(snapshot.notes_px.clamp(10.0, 72.0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
commands
|
||||
}
|
||||
}
|
||||
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
|
||||
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snapshot() -> Snapshot {
|
||||
Snapshot {
|
||||
slide: 0,
|
||||
total: 2,
|
||||
ended: false,
|
||||
notes: String::new(),
|
||||
elapsed: "00:00:00".into(),
|
||||
paused: false,
|
||||
notes_px: 18.0,
|
||||
blank: None,
|
||||
grid: false,
|
||||
zoom_armed: false,
|
||||
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
|
||||
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transport_retries_images_and_only_success_deduplicates() {
|
||||
let mut cache = DeltaCache::default();
|
||||
let first = snapshot();
|
||||
let failed = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && next);
|
||||
assert_eq!(delta.current, first.current);
|
||||
assert_eq!(delta.next, first.next);
|
||||
Err("serialization or postMessage failed")
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && next, "a failed initial send must not suppress either image");
|
||||
assert_eq!(delta.current, first.current);
|
||||
assert_eq!(delta.next, first.next);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(!current && !next);
|
||||
assert!(delta.current.is_none() && delta.next.is_none());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let mut changed = first.clone();
|
||||
changed.current.as_mut().unwrap().rgba = vec![42; 4];
|
||||
let failed = cache.send(changed.clone(), "session", false, |_, current, next| {
|
||||
assert!(current && !next);
|
||||
Err(())
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(changed.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && !next, "unchanged retry after a failed changed-image send");
|
||||
assert_eq!(delta.current, changed.current);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_refreshes_new_sessions_reloads_and_clears_previews() {
|
||||
let mut cache = DeltaCache::default();
|
||||
for (token, refresh) in [("first", false), ("second", false), ("second", true)] {
|
||||
let sent = cache.send(snapshot(), token, refresh, |delta, current, next| {
|
||||
assert!(current && next);
|
||||
assert!(delta.current.is_some() && delta.next.is_some());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
let failed = cache.send(snapshot(), "second", true, |_, current, next| {
|
||||
assert!(current && next);
|
||||
Err(())
|
||||
});
|
||||
// BrowserHost retains refresh_images after failure, even when the token is unchanged.
|
||||
let refresh_images = failed.is_err();
|
||||
let sent = cache.send(snapshot(), "second", refresh_images, |_, current, next| {
|
||||
assert!(current && next, "reload refresh must survive a failed send");
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let mut cleared = snapshot();
|
||||
cleared.current = None;
|
||||
cleared.next = None;
|
||||
let failed = cache.send(cleared.clone(), "second", false, |_, current, next| {
|
||||
assert!(current && next);
|
||||
Err(())
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(cleared.clone(), "second", false, |delta, current, next| {
|
||||
assert!(current && next, "clearing images is retried too");
|
||||
assert!(delta.current.is_none() && delta.next.is_none());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let sent = cache.send(cleared, "second", false, |_, current, next| {
|
||||
assert!(!current && !next);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_installs_interface_fonts_before_non_latin_note_layout() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut view = View::default();
|
||||
let mut data = snapshot();
|
||||
let arabic = "\u{645}\u{631}\u{62d}\u{628}\u{627}";
|
||||
let japanese = "\u{65e5}\u{672c}\u{8a9e}";
|
||||
let korean = "\u{d55c}\u{ae00}";
|
||||
data.notes = format!("{arabic} {japanese} {korean}");
|
||||
let mut installing = 0;
|
||||
let mut layouts = 0;
|
||||
for _ in 0..4 {
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
if !view.prepare(ui.ctx(), &data.notes) {
|
||||
installing += 1;
|
||||
assert!(view.ui(ui, &data).is_empty(), "no commands during a discarded font-install pass");
|
||||
return;
|
||||
}
|
||||
assert!(!ui.ctx().will_discard());
|
||||
layouts += 1;
|
||||
for (family, text) in [("Noto Sans Arabic", arabic), ("BIZ UDPGothic", japanese), ("NanumGothic", korean)] {
|
||||
if deckcraft_fonts::CRAFT_FONTS.iter().any(|face| face.family == family) {
|
||||
ui.ctx().fonts_mut(|fonts| {
|
||||
for c in text.chars() {
|
||||
assert!(fonts.has_glyph(&egui::FontId::proportional(18.0), c), "missing bundled {family} glyph {c:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = view.ui(ui, &data);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
assert!(installing > 0 && layouts > 0, "font installation must precede an actual notes layout");
|
||||
}
|
||||
#[test]
|
||||
fn popup_input_is_forwarded_once_without_advancing_its_own_state() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let snapshot = Snapshot {
|
||||
slide: 0,
|
||||
total: 3,
|
||||
ended: false,
|
||||
notes: "Synthetic notes".into(),
|
||||
elapsed: "00:00:00".into(),
|
||||
paused: false,
|
||||
notes_px: 18.0,
|
||||
blank: None,
|
||||
grid: false,
|
||||
zoom_armed: false,
|
||||
current: None,
|
||||
next: None,
|
||||
};
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
|
||||
sent.extend(view.ui(ui, snapshot));
|
||||
},
|
||||
(View::default(), snapshot, Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Next ▶").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2.len(), 1);
|
||||
h.key_press(egui::Key::Space);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2.len(), 2, "Space must not also activate the focused Next button");
|
||||
assert_eq!(h.state().0.previous_current, None);
|
||||
assert_eq!(h.state().1.slide, 0, "only an audience snapshot changes the displayed position");
|
||||
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
|
||||
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
|
||||
let p = Preview::from_image(&image).unwrap();
|
||||
assert_eq!((p.width, p.height), (640, 360));
|
||||
assert_eq!(p.image().unwrap().pixels[0], egui::Color32::RED);
|
||||
for (width, height, rgba) in [(usize::MAX, 1, vec![]), (0, 1, vec![]), (1, 1, vec![0; 3]), (641, 1, vec![0; 2564])] {
|
||||
assert!(Preview { width, height, rgba }.image().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<InboxEvent>>>;
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Browser-owned presenter popup; absent on native hosts.
|
||||
pub presenter: Option<Box<dyn crate::presenter::Host>>,
|
||||
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
|
||||
pub pick_open: Option<PickFn>,
|
||||
/// Save dialog with a suggested name → path.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub struct Show {
|
||||
/// Preview renders started on another thread.
|
||||
#[cfg(test)]
|
||||
preview_spawns: usize,
|
||||
remote_preview: Option<crate::presenter::Preview>,
|
||||
remote_next: Option<(usize, crate::presenter::Preview)>,
|
||||
remote_sent: f64,
|
||||
remote_opened: bool,
|
||||
/// The next slide's first frame, rendered in the background while this one is shown so
|
||||
/// advancing only uploads it (#240).
|
||||
prefetch: Option<Prefetch>,
|
||||
@@ -231,6 +235,10 @@ impl Show {
|
||||
next_job: None,
|
||||
#[cfg(test)]
|
||||
preview_spawns: 0,
|
||||
remote_preview: None,
|
||||
remote_next: None,
|
||||
remote_sent: f64::NEG_INFINITY,
|
||||
remote_opened: false,
|
||||
prefetch: None,
|
||||
backdrop: None,
|
||||
blank: None,
|
||||
|
||||
@@ -12,6 +12,22 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
|
||||
.collect()
|
||||
});
|
||||
let (keep, consumed) = handle_keys(show, doc, now, keys, presenter);
|
||||
for (key, modifiers) in consumed {
|
||||
ctx.input_mut(|i| i.consume_key(modifiers, key));
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
pub(super) fn handle_keys(
|
||||
show: &mut Show,
|
||||
doc: &Presentation,
|
||||
now: f64,
|
||||
keys: Vec<(egui::Key, egui::Modifiers)>,
|
||||
presenter: bool,
|
||||
) -> (bool, Vec<(egui::Key, egui::Modifiers)>) {
|
||||
let mut consumed = vec![];
|
||||
let mut keep = true;
|
||||
let mut actions = vec![];
|
||||
for (k, m) in keys {
|
||||
@@ -31,15 +47,14 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
||||
}
|
||||
// The same key must not also activate a focused presenter button or be handled again
|
||||
// by an embedded presenter viewport.
|
||||
ctx.input_mut(|i| i.consume_key(m, k));
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
consumed.push((k, m));
|
||||
}
|
||||
for action in actions {
|
||||
if !show.apply(action, doc, now) {
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
keep
|
||||
(keep, consumed)
|
||||
}
|
||||
|
||||
/// Whether the grid that the presenter key G opens is showing in this viewport.
|
||||
|
||||
@@ -1123,6 +1123,66 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() {
|
||||
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
use crate::presenter::{Command, Host, Snapshot};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
#[derive(Default)]
|
||||
struct Bridge {
|
||||
opens: usize,
|
||||
closes: usize,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
struct Mock(Rc<RefCell<Bridge>>);
|
||||
impl Host for Mock {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.0.borrow_mut().opens += 1;
|
||||
Ok(())
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
std::mem::take(&mut self.0.borrow_mut().commands)
|
||||
}
|
||||
fn update(&mut self, _: Snapshot) {}
|
||||
fn close(&mut self) {
|
||||
self.0.borrow_mut().closes += 1;
|
||||
}
|
||||
}
|
||||
let bridge = Rc::new(RefCell::new(Bridge::default()));
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let services = crate::Services { presenter: Some(Box::new(Mock(bridge.clone()))), ..Default::default() };
|
||||
let mut app = SlideApp::new(session, services);
|
||||
app.ui.presenter_view = true;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.state_mut().start_show(0, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bridge.borrow().opens, 1);
|
||||
for (key, slide) in [(egui::Key::ArrowRight, 1), (egui::Key::ArrowLeft, 0), (egui::Key::End, 2), (egui::Key::Home, 0)] {
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: key.name().into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
|
||||
}
|
||||
bridge.borrow_mut().commands.push(Command::End);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.is_none());
|
||||
assert_eq!(bridge.borrow().closes, 1);
|
||||
h.state_mut().start_show(1, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bridge.borrow().opens, 2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, 1);
|
||||
}
|
||||
|
||||
/// #964: the next-slide preview renders on another thread; the presenter frame that asks for
|
||||
/// it doesn't wait, and the preview arrives on a later frame.
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::keys::keyboard;
|
||||
use super::keys::{handle_keys, keyboard};
|
||||
use super::media::{
|
||||
Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media,
|
||||
};
|
||||
use super::presenter::{PresenterTimer, presenter_window};
|
||||
use super::presenter::{PresenterTimer, format_elapsed, presenter_window};
|
||||
use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch};
|
||||
use super::zoom::{
|
||||
Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv,
|
||||
@@ -58,6 +58,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else {
|
||||
app.show = None;
|
||||
if let Some(host) = app.services.presenter.as_mut() {
|
||||
host.close();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let now = ctx.input(|i| i.time);
|
||||
@@ -71,8 +74,35 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
show.observe_visible_frame(now, web_time::Instant::now());
|
||||
begin_show(&ctx, &mut show, &doc, now);
|
||||
begin_show(&ctx, &mut show, &doc, now, app.services.presenter.is_some());
|
||||
let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] };
|
||||
if show.presenter
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if !show.remote_opened {
|
||||
show.remote_opened = true;
|
||||
if let Err(message) = host.open() {
|
||||
app.status = Some((message, crate::now_ms()));
|
||||
}
|
||||
}
|
||||
for command in host.commands() {
|
||||
match command {
|
||||
crate::presenter::Command::End => out.keep = false,
|
||||
crate::presenter::Command::Zoom { x, y } if x.is_finite() && y.is_finite() && show.zoom_arm => {
|
||||
show.zoom = Some(Zoom::at(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
show.zoom_arm = false;
|
||||
}
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
|
||||
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0));
|
||||
if moved {
|
||||
show.last_move = now;
|
||||
@@ -94,6 +124,43 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
draw_pen_and_hints(ui, &mut show, &f, &painter, pointing);
|
||||
draw_bars(ui, &mut show, &f, &painter, &mut out);
|
||||
draw_extras(&mut show, &f, &painter, &mut out);
|
||||
if out.keep
|
||||
&& show.presenter
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if now - show.remote_sent >= 0.2 {
|
||||
let next = show.state.peek_next(&doc);
|
||||
if let Some(n) = next
|
||||
&& show.remote_next.as_ref().is_none_or(|(old, _)| *old != n)
|
||||
{
|
||||
let size =
|
||||
crate::textures::fit_size((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), 640);
|
||||
let image = deckcraft_render::render_slide(
|
||||
&doc,
|
||||
n,
|
||||
&RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() },
|
||||
);
|
||||
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
|
||||
}
|
||||
host.update(crate::presenter::Snapshot {
|
||||
slide: show.state.slide,
|
||||
total: doc.slides.len(),
|
||||
ended: show.ended,
|
||||
notes: doc.slides.get(show.state.slide).map(|s| s.notes_text()).unwrap_or_default(),
|
||||
elapsed: format_elapsed(show.timer.elapsed(now)),
|
||||
paused: show.timer.paused(),
|
||||
notes_px: show.notes_px,
|
||||
blank: show.blank_name().map(str::to_owned),
|
||||
grid: show.grid,
|
||||
zoom_armed: show.zoom_arm,
|
||||
current: show.remote_preview.clone(),
|
||||
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
|
||||
});
|
||||
show.remote_sent = now;
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
finish(app, show, &f, out.keep);
|
||||
}
|
||||
|
||||
@@ -110,7 +177,7 @@ fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) {
|
||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, remote: bool) {
|
||||
if !show.started {
|
||||
show.started = true;
|
||||
show.slide_start = now;
|
||||
@@ -121,7 +188,7 @@ fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64
|
||||
show.show_start = now;
|
||||
show.timer = PresenterTimer::start(now);
|
||||
show.last_move = now;
|
||||
if show_fills_screen(show.reading, &doc.show.show_type) {
|
||||
if show_fills_screen(show.reading, &doc.show.show_type) && !(show.presenter && remote) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
|
||||
}
|
||||
show.after_enter(doc, now);
|
||||
@@ -303,6 +370,9 @@ fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option<TextureHandle> {
|
||||
render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref())
|
||||
}
|
||||
};
|
||||
if show.remote_opened && show.presenter {
|
||||
show.remote_preview = crate::presenter::Preview::from_image(&ci);
|
||||
}
|
||||
let tex = match show.tex.take() {
|
||||
Some((_, mut h)) => {
|
||||
crate::textures::replace(ctx, &mut h, ci);
|
||||
@@ -730,7 +800,7 @@ fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out)
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// Presenter view in a second window.
|
||||
if out.keep && show.presenter && !show.reading {
|
||||
if out.keep && show.presenter && !show.reading && !show.remote_opened {
|
||||
presenter_window(ctx, show, doc, now, &mut out.keep);
|
||||
}
|
||||
}
|
||||
@@ -740,6 +810,9 @@ fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) {
|
||||
if keep {
|
||||
app.show = Some(show);
|
||||
} else {
|
||||
if let Some(host) = app.services.presenter.as_mut() {
|
||||
host.close();
|
||||
}
|
||||
app.media.stop_owned(crate::media::Owner::Show);
|
||||
if !show.reading {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Executable
+64
@@ -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
|
||||
Reference in New Issue
Block a user