mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-05 09:59:36 -06:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b5faf6d51 | ||
|
|
0b5cd0f2d6 | ||
|
|
edeb99b00e | ||
|
|
29162e66ad | ||
|
|
8435cc301f | ||
|
|
75885b49de | ||
|
|
f7b728d0c2 | ||
|
|
8f26681a1f | ||
|
|
f2356fcf63 | ||
|
|
60132037b2 | ||
|
|
ac09977c12 | ||
|
|
480cbfd4ef | ||
|
|
afc7f765c9 | ||
|
|
d9186c7ce1 | ||
|
|
75744dd67d | ||
|
|
ebd306acc1 | ||
|
|
161af3b7d5 | ||
|
|
4e4d98e69d | ||
|
|
a52b2d348f | ||
|
|
acd19ff4ac | ||
|
|
b06d60b8ed | ||
|
|
a2d9e71b5e | ||
|
|
fe35df948c | ||
|
|
ede154d6be | ||
|
|
6431460e80 | ||
|
|
af9f522c6a | ||
|
|
efd26de81d | ||
|
|
7c47d0a9ac | ||
|
|
884e3d3353 | ||
|
|
f49ff09b8a | ||
|
|
6eaae2bd8c | ||
|
|
d5471bccf0 |
Generated
+35
-6
@@ -1275,6 +1275,7 @@ dependencies = [
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1520,6 +1521,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1587,6 +1601,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -3487,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6511,11 +6545,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -153,6 +154,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
|
||||
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
|
||||
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
|
||||
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
|
||||
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
|
||||
@@ -336,6 +338,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
|
||||
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
|
||||
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
|
||||
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
|
||||
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
|
||||
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
|
||||
@@ -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", "MessageEvent", "Event", "EventTarget"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! DeckCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's, WordCraft's and PdfCraft's bridges; each is a plain object with a
|
||||
//! `type`:
|
||||
//!
|
||||
//! - `deckcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
|
||||
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
|
||||
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
|
||||
//! a damaged file open untitled.
|
||||
//! - `deckcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file DeckCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the presentation's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
|
||||
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
|
||||
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
|
||||
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
|
||||
//! Save suggests on the desktop.
|
||||
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
|
||||
//! now that file; the next Save sends its name with `false`.
|
||||
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
|
||||
//! and Print: `false`. They are copies; the presentation's file stays the same.
|
||||
//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `deckcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
|
||||
//! starts with an empty presentation instead of the sample, which gives way to the first file the
|
||||
//! parent sends; the parent guards leaving the page, and `?author=` names the user for comments.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
/// The longest user name taken from `?author=` (the preferences' own limit).
|
||||
const MAX_AUTHOR_CHARS: usize = 256;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
/// Files the parent sent (the name, if it sent one), waiting for the next frame.
|
||||
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
|
||||
/// The unsaved-changes state last reported to the parent.
|
||||
reported: Rc<Cell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the presentation showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin, opened: Rc::default(), reported: Rc::default() })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, and hear the
|
||||
/// files it sends.
|
||||
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
|
||||
self.listen(ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Exports and Print are copies.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = bridge.post_save(name, bytes, false) {
|
||||
log::error!("couldn't hand {name} to the page: {e}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs new comments.
|
||||
pub fn set_author(app: &mut SlideApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "deckcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "deckcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
/// Before each frame: open the files the parent sent.
|
||||
pub fn open_files(&self, app: &mut SlideApp) {
|
||||
let opened = std::mem::take(&mut *self.opened.borrow_mut());
|
||||
for (name, bytes) in opened {
|
||||
app.open_from_host(name.as_deref(), &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// After each frame: tell the parent when unsaved changes appear or go away.
|
||||
pub fn report_dirty(&self, app: &SlideApp) {
|
||||
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
|
||||
if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
self.reported.set(Some(dirty));
|
||||
}
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "deckcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `deckcraft:open` from the parent and queue the file for the next frame.
|
||||
fn listen(&self, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let opened = self.opened.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("deckcraft:open") {
|
||||
return;
|
||||
}
|
||||
// No name is no file: the presentation opens untitled, never under an invented name.
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
opened.borrow_mut().push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,20 @@
|
||||
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
|
||||
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
|
||||
//! that started the pick (a dismissed picker or a closed document drops the file);
|
||||
//! - Save and Export trigger a browser download;
|
||||
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - preferences are not persisted.
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
|
||||
//! empty presentation instead of the sample deck; `?show` starts the slide show.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
|
||||
//! for comments.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -251,6 +251,9 @@ impl Popup {
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
if data.slides.is_none() {
|
||||
data.slides = previous.slides.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
|
||||
@@ -24,8 +24,16 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and
|
||||
// stores what it saves (`host`). The presenter popup is never the framed page.
|
||||
let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none();
|
||||
let bridge = if host { crate::host::Bridge::new() } else { None };
|
||||
if host && bridge.is_none() {
|
||||
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
|
||||
}
|
||||
let app_bridge = bridge.clone();
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
if query_has("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -44,22 +52,42 @@ pub fn start() {
|
||||
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();
|
||||
if !q.contains("blank") {
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
// In host mode the parent sends the presentation to edit; no sample meanwhile.
|
||||
if app_bridge.is_some() {
|
||||
if let Err(e) = app.start_for_host() {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
} else if !query_has("blank") {
|
||||
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample deck: {e}");
|
||||
}
|
||||
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
if q.contains("show") {
|
||||
if query_has("show") {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -73,17 +101,36 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
|
||||
/// `?author=` is free text.
|
||||
fn query_has(key: &str) -> bool {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
app: SlideApp,
|
||||
inbox: Inbox,
|
||||
/// The parent page that keeps the files (`?host=parent`).
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
|
||||
/// page sent first and telling it about unsaved changes after.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.open_files(&mut self.app);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.report_dirty(&self.app);
|
||||
}
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
|
||||
@@ -253,8 +253,15 @@ pub struct LoadedFile {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
loaded_file(path, &bytes)
|
||||
}
|
||||
|
||||
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
|
||||
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
|
||||
/// that name like a file opened on the desktop.
|
||||
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
|
||||
let (doc, damaged) = open_checked(path, bytes)?;
|
||||
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
|
||||
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
|
||||
}
|
||||
|
||||
@@ -470,7 +477,10 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
|
||||
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
|
||||
/// page has the bytes.
|
||||
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.saved_doc = st.doc.clone();
|
||||
if path.is_some() {
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
|
||||
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let st = s.doc()?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
// In slide space, as shape.connect uses them, for a shape inside a group too.
|
||||
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -12,8 +13,13 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||||
let (a, b) = (site_list(from), site_list(to));
|
||||
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
|
||||
// glues to grouped shapes), its sites where the group draws them.
|
||||
let site_list = |id| {
|
||||
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
|
||||
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
|
||||
};
|
||||
let (a, b) = (site_list(from)?, site_list(to)?);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
|
||||
}
|
||||
@@ -35,6 +41,11 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
|
||||
// inside a group would be drawn in the group's space and never follow its shapes.
|
||||
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
|
||||
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
|
||||
}
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
|
||||
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
|
||||
// also inside a group: ungrouping would hand the flip to the graphic.
|
||||
let mut graphic = false;
|
||||
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
|
||||
if graphic {
|
||||
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
|
||||
}
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
|
||||
@@ -253,9 +253,10 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
match (idx(0), idx(1)) {
|
||||
(Some(r), Some(c)) => Ok(Some((r, c))),
|
||||
// Exactly [row, col]: an entry with more is as malformed as one with less.
|
||||
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
|
||||
match v.as_array().map(Vec::as_slice) {
|
||||
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
|
||||
}
|
||||
}
|
||||
@@ -389,33 +390,54 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// At most this many `cells` in one table cell command.
|
||||
const MAX_CELLS: usize = 100_000;
|
||||
|
||||
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
|
||||
/// caret, else every cell.
|
||||
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
|
||||
let cells: Option<Vec<(usize, usize)>> = p
|
||||
.get("cells")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
|
||||
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
|
||||
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
|
||||
// dropped or read as absent (which would act on the selection or the whole table).
|
||||
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
|
||||
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
|
||||
}
|
||||
Some(Value::Array(a)) => Some(
|
||||
a.iter()
|
||||
.map(|c| {
|
||||
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
|
||||
let cmd = cmd.to_string();
|
||||
Ok(move |t: &Table| match (&cells, sel) {
|
||||
(Some(c), _) => c.clone(),
|
||||
// Each listed cell must be in the table, so a command never reports success for a cell
|
||||
// it could not edit.
|
||||
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
|
||||
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
|
||||
None => Ok(c.clone()),
|
||||
},
|
||||
(None, Some((a, b, range))) => {
|
||||
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
|
||||
// The selection can name cells outside the table; only walk the ones that exist.
|
||||
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
|
||||
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
|
||||
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
|
||||
}
|
||||
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
|
||||
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellFill")?;
|
||||
with_table(s, p, "table.cellFill", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.fill.clone_from(&fill);
|
||||
}
|
||||
@@ -447,9 +469,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// "none" clears to no line, so the table style's own lines don't show through either.
|
||||
let line = if which == "none" { Line::none() } else { line };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.borders")?;
|
||||
with_table(s, p, "table.borders", move |t| {
|
||||
let list = targets(t);
|
||||
let list = targets(t)?;
|
||||
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
|
||||
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
|
||||
else {
|
||||
@@ -463,9 +485,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = match side {
|
||||
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
|
||||
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
|
||||
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
|
||||
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
|
||||
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
|
||||
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
|
||||
};
|
||||
if let Some((r, c, side)) = n
|
||||
&& let Some(cell) = t.cell_mut(r, c)
|
||||
@@ -523,9 +545,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
|
||||
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellMargins")?;
|
||||
with_table(s, p, "table.cellMargins", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.margins = Some(m);
|
||||
}
|
||||
@@ -560,9 +582,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellAlign")?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
|
||||
//! connectors whenever the shapes they connect move, resize or rotate.
|
||||
|
||||
use deckcraft_geom::{Point, Xfrm};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
|
||||
use crate::Selection;
|
||||
use crate::cmd::xfrm_of;
|
||||
|
||||
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
|
||||
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
|
||||
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
|
||||
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
sites_in(doc, sel, s, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
|
||||
/// slide, or the child space of the groups around it) onto the slide.
|
||||
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
|
||||
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
if local.is_empty() {
|
||||
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
|
||||
}
|
||||
let a = x.affine();
|
||||
let a = to_abs * x.affine();
|
||||
local.into_iter().map(|p| a * p).collect()
|
||||
}
|
||||
|
||||
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
|
||||
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
|
||||
/// and its sites are where the group draws them.
|
||||
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
|
||||
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some((s, to_abs));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &s.kind {
|
||||
let x = xfrm_of(doc, sel, s);
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
|
||||
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
|
||||
}
|
||||
|
||||
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
|
||||
/// no such shape.
|
||||
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
|
||||
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
|
||||
}
|
||||
|
||||
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
|
||||
pub fn nearest_site(
|
||||
doc: &Presentation,
|
||||
@@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
|
||||
let snapshot: Vec<Shape> = list.clone();
|
||||
let view = doc.clone();
|
||||
let site = |id: ShapeId, idx: u32| -> Option<Point> {
|
||||
let s = snapshot.iter().find(|s| s.id == id)?;
|
||||
let v = sites(&view, sel, s);
|
||||
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
|
||||
let v = sites_of(&view, sel, &snapshot, id)?;
|
||||
v.get(idx as usize).or(v.first()).copied()
|
||||
};
|
||||
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
|
||||
|
||||
@@ -6509,3 +6509,161 @@ fn save_to_image_path_is_an_error_not_zip_bytes() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's
|
||||
/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at
|
||||
/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes,
|
||||
/// and the sites of a grouped shape were computed in the group's child space.
|
||||
#[test]
|
||||
fn shape_connect_glues_to_a_shape_inside_a_group() {
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let glue = |s: &Session, id: u64| {
|
||||
let st = s.doc().unwrap();
|
||||
match st.shape(ShapeId(id as u32)).unwrap().kind {
|
||||
ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)),
|
||||
_ => (None, None),
|
||||
}
|
||||
};
|
||||
let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6;
|
||||
// A new connector into the group.
|
||||
let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap();
|
||||
let k = r["id"].as_u64().unwrap();
|
||||
assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}");
|
||||
let (p0, p1) = line_ends(&s, k);
|
||||
assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}");
|
||||
assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}");
|
||||
s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap();
|
||||
assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move");
|
||||
let (_, p1) = line_ends(&s, k);
|
||||
assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}");
|
||||
// Re-gluing an existing line with `id`.
|
||||
let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap();
|
||||
assert_eq!(r["id"], l, "{r}");
|
||||
assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}");
|
||||
let (_, p1) = line_ends(&s, l);
|
||||
assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}");
|
||||
s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap();
|
||||
assert_eq!(glue(&s, l), (Some(c), Some(b)));
|
||||
let (_, p1) = line_ends(&s, l);
|
||||
assert!(near(p1, 160.0, 325.0), "{p1:?}");
|
||||
// shape.sites answers in slide space, as shape.connect uses them: A's right site, A moved
|
||||
// with the group.
|
||||
let v = s.execute("shape.sites", &json!({"id": a})).unwrap();
|
||||
assert!(
|
||||
v.as_array().unwrap().iter().any(|q| near(deckcraft_geom::Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap()), 160.0, 135.0)),
|
||||
"{v}"
|
||||
);
|
||||
// A line inside a group is refused as `id`: its ends would be written in slide space into
|
||||
// the group's space, and rerouting does not follow grouped connectors.
|
||||
let m = s.execute("shape.insert", &json!({"preset": "line", "rect": [600, 300, 50, 0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let n = s.execute("shape.insert", &json!({"preset": "rect", "rect": [600, 450, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("arrange.group", &json!({"ids": [m, n]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute("shape.connect", &json!({"id": m, "from": c, "to": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("inside a group"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
// A grouped shape that is not a line is refused as not a line, not as a grouped line.
|
||||
let e = s.execute("shape.connect", &json!({"id": n, "from": c, "to": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a line"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();
|
||||
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
|
||||
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
|
||||
}
|
||||
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
|
||||
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
|
||||
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt"), "{e}");
|
||||
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
|
||||
// Still a SmartArt graphic: smartArt commands keep working.
|
||||
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
|
||||
// Ordinary shapes still flip.
|
||||
let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
|
||||
let id = b["id"].as_u64().unwrap();
|
||||
assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));
|
||||
}
|
||||
|
||||
/// Table cell commands refuse a `cells` that is not a list of [row, col] cells in the table,
|
||||
/// instead of dropping the entry or reading the list as absent (which acts on the selection).
|
||||
#[test]
|
||||
fn table_cell_commands_refuse_a_malformed_cells_list() {
|
||||
let mut s = session();
|
||||
let t = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let rev = s.doc().unwrap().revision;
|
||||
for cells in [
|
||||
json!([[-1, 0]]),
|
||||
json!([[0]]),
|
||||
json!([[0, 0], "x"]),
|
||||
json!([[0, 0, "x"]]),
|
||||
json!([[0, 2]]),
|
||||
json!([[0, u64::MAX]]),
|
||||
json!("0,0"),
|
||||
json!({"0": 0}),
|
||||
] {
|
||||
for (cmd, extra) in [
|
||||
("table.cellFill", json!({"color": "#FF0000"})),
|
||||
("table.cellMargins", json!({"left": 4})),
|
||||
("table.cellAlign", json!({"align": "center"})),
|
||||
("table.borders", json!({"which": "right"})),
|
||||
] {
|
||||
let mut p = extra.clone();
|
||||
p["id"] = json!(t);
|
||||
p["cells"] = cells.clone();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(cmd) && e.contains("cell"), "{cmd} {cells}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().revision, rev, "nothing changed");
|
||||
let many = json!(vec![[0, 0]; 100_001]);
|
||||
assert!(s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": many})).unwrap_err().to_string().contains("at most"));
|
||||
s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": [[1, 1]]})).unwrap();
|
||||
assert!(table_of(&s, t).cell(1, 1).unwrap().fill.is_some() && table_of(&s, t).cell(0, 0).unwrap().fill.is_none());
|
||||
}
|
||||
|
||||
/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer,
|
||||
/// date and slide-number placeholders are not body lines.
|
||||
#[test]
|
||||
fn outline_export_skips_footer_date_and_slide_number() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
|
||||
s.execute(
|
||||
"design.headerFooter",
|
||||
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
|
||||
)
|
||||
.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.md");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}");
|
||||
assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}");
|
||||
assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}");
|
||||
assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}");
|
||||
// The RTF outline (Save As ▸ Outline/RTF) too.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.rtf");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap();
|
||||
let rtf = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
|
||||
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
|
||||
}
|
||||
|
||||
@@ -236,7 +236,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// A connector dragged away from shapes that stay put comes unglued at those ends.
|
||||
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
|
||||
let mut moved: Vec<ShapeId> = vec![];
|
||||
for (id, _) in &origs2 {
|
||||
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
|
||||
}
|
||||
}
|
||||
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
|
||||
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
||||
for c in [start, end] {
|
||||
|
||||
@@ -694,7 +694,9 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
out.push_str(&s.title().replace('\n', " "));
|
||||
out.push('\n');
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
// The title is the slide's line; footer, date and slide-number placeholders are not
|
||||
// body text (PowerPoint's outline view shows title and body placeholders only).
|
||||
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = &sh.text {
|
||||
|
||||
@@ -71,7 +71,8 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
// As in the text outline: footer, date and slide-number placeholders are not body text.
|
||||
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
|
||||
continue;
|
||||
}
|
||||
let Some(t) = &sh.text else { continue };
|
||||
|
||||
@@ -19,6 +19,7 @@ deckcraft-anim = { workspace = true }
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
craft-ui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -106,6 +106,7 @@ impl SlideApp {
|
||||
twisting: false,
|
||||
next_file_id: 1,
|
||||
pending_files: Vec::new(),
|
||||
host_start: None,
|
||||
ime_tracker: egui::ime_context::ImeContextTracker::new(),
|
||||
ime_pending: None,
|
||||
ime_frame_viewport: None,
|
||||
|
||||
@@ -20,6 +20,11 @@ impl SlideApp {
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {
|
||||
self.run_ui(id, ¶ms)
|
||||
} else if id == "file.save" && self.services.host_save.is_some() && params.get("path").is_none() {
|
||||
// A page that keeps the files (`?host=parent`): Save by id (the command palette) goes
|
||||
// to the page like Ctrl+S. The engine would write to the presentation's path, which a
|
||||
// browser can't.
|
||||
self.save_to_host(None).map(|name| json!({"path": name}))
|
||||
} else {
|
||||
self.session.execute(id, ¶ms).map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
@@ -462,6 +462,8 @@ impl SlideApp {
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
} else if self.services.host_save.is_some() {
|
||||
let _ = self.save_to_host(Some(ext));
|
||||
} else if let Some(dl) = self.services.download.as_mut()
|
||||
&& let Some(d) = self.session.active()
|
||||
{
|
||||
@@ -487,6 +489,10 @@ impl SlideApp {
|
||||
}
|
||||
|
||||
pub fn save(&mut self) {
|
||||
if self.services.host_save.is_some() {
|
||||
let _ = self.save_to_host(None);
|
||||
return;
|
||||
}
|
||||
if self.session.active().and_then(|d| d.path.clone()).is_some() {
|
||||
match self.session.execute("file.save", &json!({})) {
|
||||
Ok(_) => self.set_status("Saved"),
|
||||
@@ -497,6 +503,85 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves to the page that keeps the files ([`Services::host_save`](crate::Services::host_save)).
|
||||
/// Save (`ext` `None`) writes the presentation's own file (the one the page sent, or the
|
||||
/// last one saved there) in that file's format, as the desktop app does; a presentation with
|
||||
/// no file of its own is a new `.deckcraft` file, the name Save suggests on the desktop. Save
|
||||
/// As (`pptx`, `deckcraft`) is a new file named after the presentation. Once the page has the
|
||||
/// bytes, the presentation is saved and that name is its file. Returns the name, or why it
|
||||
/// wasn't saved (also shown in the status bar).
|
||||
pub(super) fn save_to_host(&mut self, ext: Option<&str>) -> Result<String, String> {
|
||||
use deckcraft_engine::cmd::file::{format_for_path, mark_saved, save_bytes};
|
||||
let Some(d) = self.session.active() else { return Err("no presentation is open".into()) };
|
||||
let own = d.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
let (name, save_as) = match (ext, own) {
|
||||
(None, Some(own)) => (own, false),
|
||||
(ext, _) => (format!("{}.{}", d.title(), ext.unwrap_or("deckcraft")), true),
|
||||
};
|
||||
let saved = save_bytes(&d.doc, format_for_path(&name)).map_err(|e| e.to_string()).and_then(|bytes| {
|
||||
let host = self.services.host_save.as_mut().ok_or("no page to save to")?;
|
||||
host(&name, &bytes, save_as).map_err(|e| format!("Couldn't save {name}: {e}"))?;
|
||||
mark_saved(&mut self.session, Some(name.clone())).map_err(|e| e.to_string())
|
||||
});
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
self.set_status(format!("Saved {name}"));
|
||||
Ok(name)
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file the page that keeps the files sent (`?host=parent`), with the name it sent, if any.
|
||||
/// It opens as File › Open opens it on the desktop: a presentation as itself, an outline
|
||||
/// (.txt, .md, .rtf) as a new untitled presentation. The name is the presentation's file only
|
||||
/// when the page sent one with a presentation extension and the file is an intact
|
||||
/// presentation, so Save hands it back under that name and format; anything else (no name, an
|
||||
/// outline, a damaged file opened as a repaired copy) is untitled, and its first Save is a new
|
||||
/// file. The first presentation that opens takes the place of the empty one host mode started
|
||||
/// with ([`Self::start_for_host`]), if that one is still open, untitled and unchanged; later
|
||||
/// files open beside the others.
|
||||
pub fn open_from_host(&mut self, name: Option<&str>, bytes: &[u8]) {
|
||||
let blank = self.host_start.filter(|&uid| self.session.documents().iter().any(|d| d.uid == uid && d.path.is_none() && !d.is_dirty()));
|
||||
let before = self.session.documents().len();
|
||||
let shown = name.unwrap_or("Presentation");
|
||||
let named = name.is_some_and(|n| is_presentation_name(&n.to_ascii_lowercase()));
|
||||
let opened = deckcraft_engine::cmd::file::loaded_file(shown, bytes).and_then(|mut f| {
|
||||
// `file.adopt` keeps the path only for a native file; a name the page didn't send
|
||||
// for a presentation must not become one.
|
||||
f.native &= named;
|
||||
let token = self.session.stage_loaded(f);
|
||||
self.session.execute("file.adopt", &json!({"path": shown, "token": token}))
|
||||
});
|
||||
if let Err(e) = opened {
|
||||
self.notify(e.to_string());
|
||||
}
|
||||
self.drain_requests();
|
||||
if self.session.documents().len() <= before {
|
||||
return;
|
||||
}
|
||||
self.host_start = None;
|
||||
if let Some(opened) = self.session.active().map(|d| d.uid)
|
||||
&& let Some(i) = self.session.documents().iter().position(|d| Some(d.uid) == blank)
|
||||
{
|
||||
self.session.close_document(i);
|
||||
if let Some(i) = self.session.documents().iter().position(|d| d.uid == opened) {
|
||||
self.session.set_active(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host mode (`?host=parent`) starts with an empty presentation instead of the sample, for
|
||||
/// the first file the page sends to take its place ([`Self::open_from_host`]).
|
||||
pub fn start_for_host(&mut self) -> Result<(), String> {
|
||||
self.run("file.new", json!({}))?;
|
||||
self.host_start = self.session.active().map(|d| d.uid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Act on requests commands left for the UI.
|
||||
pub(super) fn drain_requests(&mut self) {
|
||||
for r in std::mem::take(&mut self.session.ui_requests) {
|
||||
|
||||
@@ -144,6 +144,10 @@ impl SlideApp {
|
||||
let visible = show::viewport_visible(ctx);
|
||||
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return };
|
||||
let Some(show) = self.show.as_mut() else { return };
|
||||
if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) {
|
||||
// A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866).
|
||||
show.presenter_grid = false;
|
||||
}
|
||||
if visible {
|
||||
// A visible logic-only pass (`run_logic`) carries the last full frame's input time.
|
||||
// Anchoring that stale time to `now` would move the hidden clock backwards if the
|
||||
|
||||
@@ -249,7 +249,8 @@ fn act(app: &mut SlideApp, nav: Nav, current: Page) -> Option<Page> {
|
||||
match nav {
|
||||
Nav::Page(p) => Some(p),
|
||||
Nav::Save => {
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some();
|
||||
// A page that keeps the files (`host_save`) saves as the desktop does.
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
|
||||
let has_path = app.session.active().is_some_and(|d| d.path.is_some());
|
||||
if !has_path && !web {
|
||||
// A presentation that was never saved goes to Save As first, as in PowerPoint.
|
||||
|
||||
@@ -166,15 +166,28 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out:
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
|
||||
let web = app.services.pick_save.is_none();
|
||||
// A page that keeps the files (`host_save`) saves; plain web downloads.
|
||||
let web = app.services.pick_save.is_none() && app.services.host_save.is_none();
|
||||
let label = if web { "Download" } else { "Save" };
|
||||
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
|
||||
if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() {
|
||||
if save
|
||||
.on_hover_text(if web {
|
||||
"Download the file"
|
||||
} else if app.services.host_save.is_some() {
|
||||
"Save to the page"
|
||||
} else {
|
||||
"Choose where to save"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
let fmt = bs.file_type;
|
||||
let done = if matches!(fmt, "pptx" | "deckcraft") {
|
||||
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
|
||||
// A save bumps the revision, also one under the same name that leaves the path
|
||||
// and the dirty flag as they were; a cancelled dialog or a failed save doesn't.
|
||||
let snapshot = |app: &SlideApp| app.session.active().map(|d| (d.path.clone(), d.is_dirty(), d.revision));
|
||||
let before = snapshot(app);
|
||||
app.save_as_dialog(fmt);
|
||||
web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before
|
||||
web || snapshot(app) != before
|
||||
} else {
|
||||
app.export_as(fmt, json!({}))
|
||||
};
|
||||
|
||||
@@ -207,7 +207,8 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
|
||||
let mut budget = 4u32;
|
||||
let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| {
|
||||
let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32;
|
||||
match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) {
|
||||
// Print preview matches the actual exported PDF output (always full color).
|
||||
match app.textures.thumb(ui.ctx(), doc, i, px, false, &mut budget) {
|
||||
Some(tex) => {
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
|
||||
@@ -393,6 +393,64 @@ fn ctrl_w_asks_before_closing_unsaved_changes() {
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
}
|
||||
|
||||
/// Framed by a page that keeps the files (`?host=parent`), the Close prompt's Save is a real save:
|
||||
/// when the page doesn't get the file the presentation stays open with its changes, and when it
|
||||
/// does the presentation closes. (A plain browser download closes either way.)
|
||||
#[test]
|
||||
fn host_close_prompt_keeps_the_presentation_when_the_save_fails() {
|
||||
for fail in [true, false] {
|
||||
let services = Services {
|
||||
host_save: Some(Box::new(move |_: &str, _: &[u8], _: bool| if fail { Err("the page is gone".into()) } else { Ok(()) })),
|
||||
download: Some(Box::new(|_: &str, _: &[u8]| {})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), services));
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
// Enter is the prompt's Save.
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "fail={fail}");
|
||||
let open = h.state().session.documents().len();
|
||||
assert_eq!(open, usize::from(fail), "fail={fail}: open presentations");
|
||||
if fail {
|
||||
assert!(h.state().session.active().unwrap().is_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Framed by a page that keeps the files, Save As › Save under the presentation's own name goes
|
||||
/// back to the slide once the page has the file. The bug this prevents: that save leaves the
|
||||
/// file and the dirty flag as they were, so the Save As page stayed open and a second click sent
|
||||
/// the file again.
|
||||
#[test]
|
||||
fn host_save_as_under_the_same_name_returns_to_the_slide() {
|
||||
let got: Rc<RefCell<Vec<(String, bool)>>> = Default::default();
|
||||
let saves = got.clone();
|
||||
let services = Services {
|
||||
host_save: Some(Box::new(move |name: &str, _: &[u8], save_as: bool| {
|
||||
saves.borrow_mut().push((name.to_string(), save_as));
|
||||
Ok(())
|
||||
})),
|
||||
download: Some(Box::new(|_: &str, _: &[u8]| {})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
app.start_for_host().unwrap();
|
||||
let deck = deckcraft_engine::cmd::file::save_bytes(&Session::with_new().doc().unwrap().doc, "pptx").unwrap();
|
||||
app.open_from_host(Some("deck.pptx"), &deck);
|
||||
let mut h = harness(app);
|
||||
h.state_mut().run("app.backstage", json!({"page": "saveAs"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Save");
|
||||
h.run_steps(3);
|
||||
assert_eq!(*got.borrow(), [("deck.pptx".to_string(), true)]);
|
||||
assert!(h.state().backstage.is_none(), "the save returns to the slide");
|
||||
}
|
||||
|
||||
/// Info shows the presentation's counts and properties, and edits them through file.properties.
|
||||
#[test]
|
||||
fn info_shows_and_edits_the_properties() {
|
||||
|
||||
@@ -299,7 +299,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
vec2(w as f32 * scale, h as f32 * scale),
|
||||
);
|
||||
let mut budget = 2;
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) {
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, app.ui.grayscale, &mut budget) {
|
||||
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, pos2, theme, vec2};
|
||||
|
||||
/// Room the About credits need for their table; the About tab keeps its compact size.
|
||||
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0);
|
||||
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(820.0, 380.0);
|
||||
|
||||
/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the
|
||||
/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first
|
||||
|
||||
@@ -339,8 +339,9 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool {
|
||||
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
|
||||
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
app.save();
|
||||
// In the browser a save is a download: the presentation stays "unsaved".
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some();
|
||||
// In the browser a save is a download: the presentation stays "unsaved". A page
|
||||
// that keeps the files saves it for real, so a failed save keeps it open.
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
|
||||
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
|
||||
run(app, "file.close", json!({}));
|
||||
}
|
||||
|
||||
@@ -246,12 +246,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -260,39 +262,153 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(k.label().1).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
@@ -302,22 +418,48 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -397,4 +539,131 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! The web build framed by a page that keeps the files (`?host=parent`): what Save, Save As,
|
||||
//! the exports and Print hand that page, under which name, and whether it is a new file.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Services, SlideApp};
|
||||
use crate::Session;
|
||||
|
||||
/// What the page was handed: (name, Save As, bytes).
|
||||
type Got = Rc<RefCell<Vec<(String, bool, Vec<u8>)>>>;
|
||||
|
||||
/// The web app inside such a page, with `host_save` and `download` pointed at it as the bridge
|
||||
/// does; `fail` makes every save miss the page.
|
||||
fn host_app(fail: bool) -> (SlideApp, Got) {
|
||||
let got: Got = Rc::default();
|
||||
let (saves, downloads) = (got.clone(), got.clone());
|
||||
let services = Services {
|
||||
host_save: Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
if fail {
|
||||
return Err("the page is gone".into());
|
||||
}
|
||||
saves.borrow_mut().push((name.to_string(), save_as, bytes.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
download: Some(Box::new(move |name: &str, bytes: &[u8]| downloads.borrow_mut().push((name.to_string(), false, bytes.to_vec())))),
|
||||
..Default::default()
|
||||
};
|
||||
// Host mode starts as the web shell starts it: an empty presentation for the page's file.
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
app.start_for_host().unwrap();
|
||||
(app, got)
|
||||
}
|
||||
|
||||
/// A .pptx with `slides` slides, as the page would send it.
|
||||
fn pptx(slides: usize) -> Vec<u8> {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().unwrap().doc.slides.len() < slides {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap()
|
||||
}
|
||||
|
||||
fn edit(app: &mut SlideApp) {
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
fn calls(got: &Got) -> Vec<(String, bool)> {
|
||||
got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect()
|
||||
}
|
||||
|
||||
fn path(app: &SlideApp) -> Option<String> {
|
||||
app.session.active().and_then(|d| d.path.clone())
|
||||
}
|
||||
|
||||
/// The bug this prevents: Save of a .pptx the page sent came back as `<name>.deckcraft`. Save
|
||||
/// now hands the page the opened file under its own name and format, not as a new file; Save As
|
||||
/// says it is a new file and becomes the file the next Save writes.
|
||||
#[test]
|
||||
fn host_saves_go_back_to_the_opened_file() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("Lecture 3.pptx"), &pptx(2));
|
||||
assert_eq!(app.session.documents().len(), 1, "the untouched blank presentation gave way");
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
edit(&mut app);
|
||||
app.save_as_dialog("deckcraft");
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
let names = [("Lecture 3.pptx", false), ("Lecture 3.deckcraft", true), ("Lecture 3.deckcraft", false)];
|
||||
assert_eq!(calls(&got), names.map(|(n, s)| (n.to_string(), s)));
|
||||
// The first save is a .pptx that reopens with the edit in it.
|
||||
let first = got.borrow()[0].2.clone();
|
||||
assert!(first.windows(20).any(|w| w == b"ppt/presentation.xml"), "Save writes PowerPoint, not .deckcraft");
|
||||
let reopened = deckcraft_engine::cmd::file::loaded_file("Lecture 3.pptx", &first).unwrap();
|
||||
assert_eq!(reopened.doc.slides.len(), 3);
|
||||
}
|
||||
|
||||
/// A presentation the page never sent is a new file, as Save As: the page must not overwrite a
|
||||
/// file of the same name. The next Save goes back to it.
|
||||
#[test]
|
||||
fn host_save_of_an_untitled_presentation_is_save_as() {
|
||||
let (mut app, got) = host_app(false);
|
||||
let name = format!("{}.deckcraft", app.session.active().unwrap().title());
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [(name.clone(), true), (name, false)]);
|
||||
}
|
||||
|
||||
/// A presentation the user dropped (or picked with File › Open) didn't come from the page, so
|
||||
/// its name isn't one the page holds: Save makes a new file.
|
||||
#[test]
|
||||
fn a_file_the_page_never_sent_saves_as_a_new_file() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_or_insert_bytes("dropped.pptx", &pptx(1));
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [("dropped.deckcraft".to_string(), true)]);
|
||||
}
|
||||
|
||||
/// Exports and Print are copies: named after the presentation, not a new file, and the
|
||||
/// presentation keeps its own file and its unsaved changes.
|
||||
#[test]
|
||||
fn host_exports_and_print_are_copies() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
edit(&mut app);
|
||||
assert!(app.export_as("pdf", json!({})));
|
||||
assert!(app.print_pdf(json!({})));
|
||||
assert_eq!(calls(&got), [("deck.pdf".to_string(), false), ("deck.pdf".to_string(), false)]);
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
|
||||
}
|
||||
|
||||
/// The page not getting the file is a failed save: the presentation keeps its changes.
|
||||
#[test]
|
||||
fn a_host_save_that_fails_leaves_the_presentation_unsaved() {
|
||||
let (mut app, _) = host_app(true);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
assert!(app.status.as_ref().is_some_and(|(m, _)| m.contains("Couldn't save deck.pptx")));
|
||||
}
|
||||
|
||||
/// Only an untouched start presentation gives way: one with changes stays open beside the file.
|
||||
#[test]
|
||||
fn an_edited_start_presentation_stays_open() {
|
||||
let (mut app, _) = host_app(false);
|
||||
edit(&mut app);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
|
||||
}
|
||||
|
||||
/// Only the first file from the page takes the start presentation's place. The bug this
|
||||
/// prevents: a file sent without a name opens untitled and unchanged, as the start presentation
|
||||
/// is, so the page's next file closed it.
|
||||
#[test]
|
||||
fn only_the_first_file_from_the_page_replaces_the_start_presentation() {
|
||||
let (mut app, _) = host_app(false);
|
||||
app.open_from_host(None, &pptx(2));
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(3));
|
||||
let slides: Vec<usize> = app.session.documents().iter().map(|d| d.doc.slides.len()).collect();
|
||||
assert_eq!(slides, [2, 3], "the file sent without a name stays open beside the next one");
|
||||
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
|
||||
}
|
||||
|
||||
/// The command palette runs `file.save` by id. With a file of its own the engine would write to
|
||||
/// that path, which a browser can't; in host mode it goes to the page like Ctrl+S.
|
||||
#[test]
|
||||
fn palette_save_goes_to_the_page() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
edit(&mut app);
|
||||
let r = app.run("file.save", json!({})).unwrap();
|
||||
assert_eq!(r["path"], "deck.pptx");
|
||||
assert_eq!(calls(&got), [("deck.pptx".to_string(), false)]);
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
/// A file the page sent without a name, or with a name that isn't a presentation's, has no file
|
||||
/// of its own: Save is a new `.deckcraft`, never an overwrite of an invented `Presentation.pptx`.
|
||||
#[test]
|
||||
fn a_file_sent_without_a_presentation_name_saves_as_a_new_file() {
|
||||
for (name, saved) in [(None, "Presentation.deckcraft"), (Some("Talk"), "Talk.deckcraft")] {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(name, &pptx(2));
|
||||
assert_eq!(app.session.documents().len(), 1, "{name:?}");
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), 2, "{name:?}");
|
||||
assert_eq!(path(&app), None, "{name:?}");
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [(saved.to_string(), true)], "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline from the page opens as a new untitled presentation, as File › Open does on the
|
||||
/// desktop: the blank start presentation gives way, and Save is a new file.
|
||||
#[test]
|
||||
fn an_outline_from_the_page_opens_as_a_new_presentation() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("notes.md"), b"# One\n\n# Two\n\n# Three\n");
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), 3);
|
||||
assert_eq!(path(&app), None);
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [("notes.deckcraft".to_string(), true)]);
|
||||
}
|
||||
|
||||
/// A damaged file from the page opens as an untitled "(Repaired)" copy, so Save is a new file
|
||||
/// and can't overwrite the page's original with the emptied media.
|
||||
#[test]
|
||||
fn a_damaged_file_from_the_page_saves_as_a_new_repaired_file() {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
|
||||
let mut bad = deckcraft_engine::cmd::file::save_bytes(&p, "deckcraft").unwrap();
|
||||
// Pictures are stored as they are, so flipping a byte breaks the entry's CRC.
|
||||
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
|
||||
bad[at] ^= 0xff;
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("talk.deckcraft"), &bad);
|
||||
assert_eq!(path(&app), None);
|
||||
assert!(app.session.active().unwrap().title().ends_with("(Repaired)"));
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [("talk (Repaired).deckcraft".to_string(), true)]);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ pub use panels::notes;
|
||||
pub use panels::selpane;
|
||||
pub use panels::thumbs;
|
||||
pub use ribbon::qat;
|
||||
pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
|
||||
pub use services::{DownloadFn, HostSaveFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
|
||||
use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp};
|
||||
pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE};
|
||||
|
||||
@@ -335,6 +335,9 @@ pub struct SlideApp {
|
||||
next_file_id: u64,
|
||||
/// Picks and drops whose bytes have not arrived yet.
|
||||
pending_files: Vec<PendingFile>,
|
||||
/// The empty presentation host mode starts with ([`SlideApp::start_for_host`]), until the
|
||||
/// first file the page sends takes its place.
|
||||
host_start: Option<u64>,
|
||||
/// Owner/generation/cause bookkeeping for the canvas text IME producer.
|
||||
ime_tracker: egui::ime_context::ImeContextTracker,
|
||||
/// Canvas IME geometry for this pass: the caret while editing, or the selected shape.
|
||||
@@ -487,5 +490,8 @@ mod datetime_tests;
|
||||
#[cfg(test)]
|
||||
mod inbox_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_save_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -219,7 +219,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
|
||||
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
|
||||
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, app.ui.grayscale, &mut budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(
|
||||
|
||||
@@ -49,10 +49,15 @@ pub struct Snapshot {
|
||||
pub paused: bool,
|
||||
pub notes_px: f32,
|
||||
pub blank: Option<String>,
|
||||
/// All slides is open in the presenter window (#1866); the audience keeps the current slide.
|
||||
pub grid: bool,
|
||||
pub zoom_armed: bool,
|
||||
pub current: Option<Preview>,
|
||||
pub next: Option<Preview>,
|
||||
/// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta
|
||||
/// means "unchanged since the last snapshot the window received".
|
||||
#[serde(default)]
|
||||
pub slides: Option<Vec<Preview>>,
|
||||
}
|
||||
|
||||
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||
@@ -60,6 +65,7 @@ pub struct Snapshot {
|
||||
pub struct DeltaCache {
|
||||
current: Option<Preview>,
|
||||
next: Option<Preview>,
|
||||
slides: Option<Vec<Preview>>,
|
||||
token: String,
|
||||
}
|
||||
impl DeltaCache {
|
||||
@@ -75,6 +81,12 @@ impl DeltaCache {
|
||||
let next_changed = fresh || self.next != snapshot.next;
|
||||
let current = snapshot.current.clone();
|
||||
let next = snapshot.next.clone();
|
||||
let slides = snapshot.slides.clone();
|
||||
if !fresh && self.slides == slides {
|
||||
snapshot.slides = None;
|
||||
} else if snapshot.slides.is_none() {
|
||||
snapshot.slides = Some(Vec::new());
|
||||
}
|
||||
if !current_changed {
|
||||
snapshot.current = None;
|
||||
}
|
||||
@@ -84,14 +96,28 @@ impl DeltaCache {
|
||||
send(snapshot, current_changed, next_changed)?;
|
||||
self.current = current;
|
||||
self.next = next;
|
||||
self.slides = slides;
|
||||
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 },
|
||||
Key {
|
||||
name: String,
|
||||
command: bool,
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
ctrl: bool,
|
||||
},
|
||||
Zoom {
|
||||
x: f32,
|
||||
y: f32,
|
||||
},
|
||||
/// A slide chosen in the presenter window's All slides grid (#1866).
|
||||
Goto {
|
||||
slide: usize,
|
||||
},
|
||||
End,
|
||||
}
|
||||
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
|
||||
@@ -112,6 +138,8 @@ pub struct View {
|
||||
next: Option<egui::TextureHandle>,
|
||||
previous_current: Option<Preview>,
|
||||
previous_next: Option<Preview>,
|
||||
grid_source: Option<Vec<Preview>>,
|
||||
grid: Vec<Option<egui::TextureHandle>>,
|
||||
}
|
||||
impl View {
|
||||
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||
@@ -147,6 +175,10 @@ impl View {
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
for (key, modifiers) in keys {
|
||||
if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) {
|
||||
// A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open.
|
||||
continue;
|
||||
}
|
||||
// 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 {
|
||||
@@ -200,6 +232,11 @@ impl View {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if snapshot.grid {
|
||||
// All slides opens here, as in the desktop Presenter View; the audience keeps the current slide.
|
||||
self.grid_ui(ui, snapshot, &mut commands);
|
||||
return commands;
|
||||
}
|
||||
ui.columns(2, |columns| {
|
||||
let left = &mut columns[0];
|
||||
if snapshot.ended {
|
||||
@@ -227,9 +264,6 @@ impl View {
|
||||
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 {
|
||||
@@ -260,6 +294,43 @@ impl View {
|
||||
commands
|
||||
}
|
||||
}
|
||||
impl View {
|
||||
fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec<Command>) {
|
||||
let slides = snapshot.slides.as_deref().unwrap_or_default();
|
||||
if self.grid_source.as_deref() != Some(slides) {
|
||||
self.grid_source = Some(slides.to_vec());
|
||||
self.grid = slides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR)))
|
||||
.collect();
|
||||
}
|
||||
ui.heading("All slides");
|
||||
ui.label("Choose a slide to show it, or press G to return.");
|
||||
if snapshot.total > crate::show::GRID_CAP {
|
||||
ui.label(format!("First {} slides", crate::show::GRID_CAP));
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, texture) in self.grid.iter().enumerate() {
|
||||
let size = egui::vec2(160.0, 90.0);
|
||||
let label = format!("{}", i + 1);
|
||||
let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) };
|
||||
let response = match texture {
|
||||
Some(t) => ui.add(egui::Button::image_and_text(
|
||||
egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)),
|
||||
label,
|
||||
)),
|
||||
None => ui.add_sized(size, egui::Button::new(label)),
|
||||
};
|
||||
if response.clicked() {
|
||||
commands.push(Command::Goto { slide: i });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -282,6 +353,7 @@ mod tests {
|
||||
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] }),
|
||||
slides: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +490,7 @@ mod tests {
|
||||
zoom_armed: false,
|
||||
current: None,
|
||||
next: None,
|
||||
slides: 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>)| {
|
||||
@@ -437,6 +510,30 @@ mod tests {
|
||||
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_slides_opens_in_the_popup_and_picks_a_slide() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] };
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.total = 3;
|
||||
snapshot.grid = true;
|
||||
snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]);
|
||||
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);
|
||||
assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)");
|
||||
h.get_by_label("3").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
|
||||
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
|
||||
|
||||
@@ -11,6 +11,10 @@ pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>;
|
||||
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
|
||||
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
|
||||
/// the file, and the save failed.
|
||||
pub type HostSaveFn = Box<dyn FnMut(&str, &[u8], bool) -> Result<(), String>>;
|
||||
|
||||
/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InboxFile {
|
||||
@@ -49,6 +53,15 @@ pub struct Services {
|
||||
pub open_async: Option<OpenAsyncFn>,
|
||||
/// Hand bytes to the user as a named file (browser download).
|
||||
pub download: Option<DownloadFn>,
|
||||
/// Web, framed by a page that keeps the files (`?host=parent`): Save and Save As hand the
|
||||
/// presentation to that page instead of downloading it, with whether it is a new file
|
||||
/// ([`HostSaveFn`]). Save writes a presentation the page sent back under its own name and
|
||||
/// format, as the desktop app writes back to the file it opened; a presentation the page
|
||||
/// never sent (an untitled one, a repaired copy) goes as a new `.deckcraft` file, as Save
|
||||
/// suggests on the desktop. Save As (.pptx or .deckcraft) is always a new file. Either way the
|
||||
/// presentation is then that file, and the next Save goes to it. Exports and Print still go
|
||||
/// through `download`, which the host also points at the page: they are copies.
|
||||
pub host_save: Option<HostSaveFn>,
|
||||
pub inbox: Option<Inbox>,
|
||||
/// Image on the system clipboard (PNG bytes), when the host can read one.
|
||||
pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
|
||||
|
||||
@@ -18,6 +18,7 @@ mod presenter;
|
||||
mod render;
|
||||
mod ui;
|
||||
mod zoom;
|
||||
pub(crate) use zoom::GRID_CAP;
|
||||
|
||||
#[cfg(test)]
|
||||
use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars};
|
||||
@@ -65,6 +66,8 @@ pub struct Show {
|
||||
preview_spawns: usize,
|
||||
remote_preview: Option<crate::presenter::Preview>,
|
||||
remote_next: Option<(usize, crate::presenter::Preview)>,
|
||||
/// All slides thumbnails sent to the web presenter window while its grid is open (#1866).
|
||||
remote_grid: Vec<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
|
||||
@@ -237,6 +240,7 @@ impl Show {
|
||||
preview_spawns: 0,
|
||||
remote_preview: None,
|
||||
remote_next: None,
|
||||
remote_grid: vec![],
|
||||
remote_sent: f64::NEG_INFINITY,
|
||||
remote_opened: false,
|
||||
prefetch: None,
|
||||
|
||||
@@ -1132,6 +1132,8 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
opens: usize,
|
||||
closes: usize,
|
||||
commands: Vec<Command>,
|
||||
last: Option<Snapshot>,
|
||||
gone: bool,
|
||||
}
|
||||
struct Mock(Rc<RefCell<Bridge>>);
|
||||
impl Host for Mock {
|
||||
@@ -1140,12 +1142,14 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
Ok(())
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
true
|
||||
!self.0.borrow().gone
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
std::mem::take(&mut self.0.borrow_mut().commands)
|
||||
}
|
||||
fn update(&mut self, _: Snapshot) {}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
self.0.borrow_mut().last = Some(snapshot);
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.borrow_mut().closes += 1;
|
||||
}
|
||||
@@ -1179,6 +1183,33 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
|
||||
}
|
||||
// #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide.
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
let show = h.state().show.as_ref().unwrap();
|
||||
assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's");
|
||||
for _ in 0..20 {
|
||||
h.step();
|
||||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||||
}
|
||||
let last = bridge.borrow().last.clone().unwrap();
|
||||
assert!(last.grid);
|
||||
assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide");
|
||||
assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message");
|
||||
assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews");
|
||||
bridge.borrow_mut().commands.push(Command::Goto { slide: 2 });
|
||||
h.run_steps(2);
|
||||
let show = h.state().show.as_ref().unwrap();
|
||||
assert!(!show.presenter_grid && !show.grid);
|
||||
assert_eq!(show.state.slide, 2);
|
||||
// Closing the popup with its grid open must not hold auto-advance for the rest of the show.
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.as_ref().unwrap().presenter_grid);
|
||||
bridge.borrow_mut().gone = true;
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().show.as_ref().unwrap().presenter_grid);
|
||||
bridge.borrow_mut().gone = false;
|
||||
bridge.borrow_mut().commands.push(Command::End);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.is_none());
|
||||
|
||||
@@ -80,6 +80,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if show.remote_opened && show.presenter_grid && !host.is_open() {
|
||||
// A grid left open in the closed popup would hold auto-advance for the rest of the show.
|
||||
show.presenter_grid = false;
|
||||
}
|
||||
if !show.remote_opened {
|
||||
show.remote_opened = true;
|
||||
if let Err(message) = host.open() {
|
||||
@@ -96,9 +100,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::from_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;
|
||||
// The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866).
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0;
|
||||
}
|
||||
}
|
||||
crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => {
|
||||
show.presenter_grid = false;
|
||||
show.blank = None;
|
||||
show.zoom = None;
|
||||
show.zoom_arm = false;
|
||||
let action = show.state.goto(&doc, slide);
|
||||
out.keep &= show.apply(action, &doc, now);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +156,20 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
);
|
||||
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
|
||||
}
|
||||
if !show.presenter_grid {
|
||||
show.remote_grid.clear();
|
||||
} else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() {
|
||||
// A few thumbnails per update keep the audience window responsive on the web.
|
||||
for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) {
|
||||
// At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit.
|
||||
let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
|
||||
let scale = REMOTE_THUMB / w.max(h);
|
||||
let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
|
||||
let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() });
|
||||
let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false));
|
||||
show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] }));
|
||||
}
|
||||
}
|
||||
host.update(crate::presenter::Snapshot {
|
||||
slide: show.state.slide,
|
||||
total: doc.slides.len(),
|
||||
@@ -152,10 +179,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
paused: show.timer.paused(),
|
||||
notes_px: show.notes_px,
|
||||
blank: show.blank_name().map(str::to_owned),
|
||||
grid: show.grid,
|
||||
grid: show.presenter_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()),
|
||||
// The grid replaces both previews in the popup; leaving them out keeps one message small.
|
||||
current: show.remote_preview.clone().filter(|_| !show.presenter_grid),
|
||||
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()),
|
||||
slides: show.presenter_grid.then(|| show.remote_grid.clone()),
|
||||
});
|
||||
show.remote_sent = now;
|
||||
}
|
||||
@@ -164,6 +193,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
finish(app, show, &f, out.keep);
|
||||
}
|
||||
|
||||
/// Longest side of an All slides thumbnail sent to the web presenter window (#1866).
|
||||
const REMOTE_THUMB: f64 = 96.0;
|
||||
|
||||
fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
|
||||
if show.started {
|
||||
let full = ui.max_rect();
|
||||
|
||||
@@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
|
||||
/// PowerPoint's slide show magnifier goes up to 400%.
|
||||
const WHEEL_ZOOM_MAX: f32 = 4.0;
|
||||
|
||||
pub(super) const GRID_CAP: usize = 48;
|
||||
pub(crate) const GRID_CAP: usize = 48;
|
||||
|
||||
const TOOL_BAR_W: f32 = 520.0;
|
||||
|
||||
|
||||
@@ -209,7 +209,8 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
||||
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
|
||||
// than the thumbnail cache does not evict its own thumbnails forever (#969).
|
||||
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
|
||||
let tex =
|
||||
if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, app.ui.grayscale, budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
@@ -391,7 +392,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 2;
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, app.ui.grayscale, &mut budget) {
|
||||
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
|
||||
@@ -476,7 +477,7 @@ fn outline_estimate(s: &deckcraft_model::Slide) -> f32 {
|
||||
let lines: usize = s
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title()))
|
||||
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()))
|
||||
.filter_map(|x| x.text.as_ref())
|
||||
.filter(|tb| !tb.is_empty())
|
||||
.map(|tb| tb.paragraphs.len().max(1))
|
||||
@@ -512,7 +513,8 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
});
|
||||
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
|
||||
// Footer, date and slide-number placeholders are not outline text, as in PowerPoint.
|
||||
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) {
|
||||
let Some(tb) = &sh.text else { continue };
|
||||
if tb.is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -259,3 +259,24 @@ fn shift_and_ctrl_shift_clicks_select_ranges() {
|
||||
click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT);
|
||||
assert_eq!(selected(&h), vec![0, 2, 3]);
|
||||
}
|
||||
|
||||
/// The outline view lists a slide's title and body text, not its footer, date and slide-number
|
||||
/// placeholders, as PowerPoint's does: turning them on leaves the slide's outline block as it was.
|
||||
#[test]
|
||||
fn the_outline_view_leaves_footer_date_and_slide_number_out() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
|
||||
let slide = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
st.doc.slides[st.selection.slide].clone()
|
||||
};
|
||||
let plain = super::outline_estimate(&slide(&s));
|
||||
s.execute(
|
||||
"design.headerFooter",
|
||||
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
|
||||
)
|
||||
.unwrap();
|
||||
let with_footer = slide(&s);
|
||||
assert!(with_footer.shapes.iter().any(|x| x.ph_type().is_some_and(|k| k.is_footer_kind())), "the footers are on the slide");
|
||||
assert_eq!(super::outline_estimate(&with_footer), plain);
|
||||
}
|
||||
|
||||
@@ -361,9 +361,19 @@ impl Textures {
|
||||
self.thumbs.clear();
|
||||
}
|
||||
|
||||
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
|
||||
/// it is over budget, so the caller draws a placeholder and asks for another frame).
|
||||
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
|
||||
/// A thumbnail texture; renders at most `budget` new thumbnails per frame. Over budget it
|
||||
/// returns the slide's previous thumbnail if there is one (and asks for another frame), else
|
||||
/// None, so the caller draws a placeholder.
|
||||
/// When `grayscale` is true, the thumbnail renders in black-and-white (matching Grayscale view).
|
||||
pub fn thumb(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
p: &Presentation,
|
||||
index: usize,
|
||||
width_px: u32,
|
||||
grayscale: bool,
|
||||
budget: &mut u32,
|
||||
) -> Option<TextureHandle> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
|
||||
@@ -372,7 +382,7 @@ impl Textures {
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
size: (width_px, h),
|
||||
edit: false,
|
||||
extra: 0,
|
||||
extra: grayscale as u64,
|
||||
want: Region::slide(width_px, h),
|
||||
editing_text: None,
|
||||
};
|
||||
@@ -385,7 +395,8 @@ impl Textures {
|
||||
return Some(e.tex.clone());
|
||||
}
|
||||
if *budget == 0 {
|
||||
// Keep showing the stale thumbnail while the new one waits.
|
||||
// Keep showing the stale thumbnail while the new one waits; request a repaint to finish rendering.
|
||||
ctx.request_repaint();
|
||||
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
|
||||
}
|
||||
*budget -= 1;
|
||||
@@ -393,7 +404,7 @@ impl Textures {
|
||||
let scale = tw as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
|
||||
self.thumb_renders += 1;
|
||||
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
|
||||
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, grayscale));
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
@@ -433,6 +444,20 @@ mod tests {
|
||||
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_color_image_turns_colors_to_grayscale_when_flag_is_set() {
|
||||
let img = deckcraft_render::Image { width: 2, height: 1, pixels: vec![0, 0, 255, 255, 255, 0, 0, 255] };
|
||||
let color = super::to_color_image(&img, false);
|
||||
assert_eq!(color.pixels[0], egui::Color32::from_rgba_premultiplied(0, 0, 255, 255));
|
||||
assert_eq!(color.pixels[1], egui::Color32::from_rgba_premultiplied(255, 0, 0, 255));
|
||||
|
||||
let gray = super::to_color_image(&img, true);
|
||||
assert_eq!(gray.pixels[0].r(), gray.pixels[0].g());
|
||||
assert_eq!(gray.pixels[0].g(), gray.pixels[0].b());
|
||||
assert_eq!(gray.pixels[1].r(), gray.pixels[1].g());
|
||||
assert_eq!(gray.pixels[1].g(), gray.pixels[1].b());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_scale_does_not_move_the_rect() {
|
||||
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
|
||||
|
||||
+66
-2
@@ -65,12 +65,75 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
||||
- The app fills the iframe and follows its size, so size the iframe and not the app.
|
||||
- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
|
||||
- **Cross-origin embeds** work. The web build keeps no preferences between visits; it starts
|
||||
with the sample deck (or `?blank`) every time.
|
||||
with the sample deck (or `?blank`) every time. Opening and saving through the page
|
||||
(`?host=parent`, below) needs the same origin.
|
||||
- **Sandboxed iframes** need at least
|
||||
`sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without `allow-downloads`, Save and Export (browser
|
||||
downloads) are blocked.
|
||||
downloads) are blocked. With `?host=parent`, `allow-scripts allow-same-origin` is enough for
|
||||
opening and saving (without `allow-same-origin` the frame's origin is `null` and there is no
|
||||
bridge), and `allow-downloads` isn't needed.
|
||||
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
|
||||
|
||||
## Opening and saving through the page (`?host=parent`)
|
||||
|
||||
A page that keeps the files itself (a Nextcloud app, a file server) can frame DeckCraft with
|
||||
`?host=parent` on the iframe `src`, hand it a presentation and take back what it saves, instead of
|
||||
the user downloading. GridCraft, WordCraft and PdfCraft have the same bridge with their own prefix.
|
||||
The page and DeckCraft must be on the **same origin**: DeckCraft only hears and answers its parent
|
||||
window on its own origin, and without one it downloads as usual.
|
||||
|
||||
Messages are plain objects with a `type`:
|
||||
|
||||
| Message | Direction | Fields |
|
||||
|---|---|---|
|
||||
| `deckcraft:ready` | to the page | `version`. The app runs and listens: send the file now. |
|
||||
| `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. |
|
||||
| `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. |
|
||||
| `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. |
|
||||
| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2). |
|
||||
|
||||
`saveAs` says whether the file becomes the presentation's file, as on the desktop:
|
||||
|
||||
- **Save** sends the file the page sent, under its own name and format (`Talk.pptx` comes back
|
||||
as `Talk.pptx`), with `saveAs: false`. DeckCraft keeps only the part after the last `/`, so
|
||||
`docs/Talk.pptx` also comes back as `Talk.pptx`. A presentation that has no file the page sent
|
||||
(a new one, an outline, a file sent without a name, a file the user dropped, a repaired copy of
|
||||
a damaged file) is a new file with `saveAs: true`: Ctrl+S names it `<title>.deckcraft` as Save
|
||||
suggests on the desktop, and File › Save opens the Save As page first, as on the desktop.
|
||||
- **Save As** (.pptx or .deckcraft) sends `saveAs: true`. The presentation is now that file; the
|
||||
next Save sends its name with `saveAs: false`.
|
||||
- **Exports and Print** (PDF, handouts, pictures, outline, GIF) send `saveAs: false` and are
|
||||
copies: the presentation keeps its file and its unsaved changes.
|
||||
|
||||
`saveAs: false` doesn't mean "safe to overwrite": an export is `false` under a name the page may
|
||||
already hold (`Talk.pdf`, or `Talk.pptx` exported from a presentation that is now
|
||||
`Talk.deckcraft`). Replace a file without asking only when the name is the one the page sent, or
|
||||
the name of the last `saveAs: true` file it stored for this presentation; ask for anything else.
|
||||
DeckCraft doesn't learn the name the page finally stores a Save As under, so a page that renames
|
||||
on a conflict should map later Saves of the old name itself.
|
||||
|
||||
In host mode DeckCraft starts with an empty presentation instead of the sample, and the first
|
||||
presentation the page sends takes its place unless the user has changed it; later ones open
|
||||
beside it. The page guards leaving itself (DeckCraft adds no guard of its own), and
|
||||
`&author=Name` signs the user's comments.
|
||||
|
||||
```html
|
||||
<iframe id="deck" src="/deckcraft/?host=parent&author=Ada" style="width: 100%; height: 720px; border: 0;"></iframe>
|
||||
<script>
|
||||
const frame = document.getElementById("deck");
|
||||
addEventListener("message", async (e) => {
|
||||
if (e.origin !== location.origin || e.source !== frame.contentWindow) return;
|
||||
if (e.data.type === "deckcraft:ready") {
|
||||
const bytes = await (await fetch("/files/Talk.pptx")).arrayBuffer();
|
||||
frame.contentWindow.postMessage({ type: "deckcraft:open", name: "Talk.pptx", bytes }, location.origin, [bytes]);
|
||||
} else if (e.data.type === "deckcraft:save") {
|
||||
// Store e.data.bytes as e.data.name. Replace silently only when !e.data.saveAs and the name
|
||||
// is the one sent (or the last saveAs file stored); ask before replacing anything else.
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
@@ -82,6 +145,7 @@ DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall
|
||||
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
|
||||
| `?blank` | Start with an empty presentation instead of the sample deck |
|
||||
| `?show` | Start the slide show immediately (kiosk/embedded decks) |
|
||||
| `?host=parent` | Open and save through the framing page ([above](#opening-and-saving-through-the-page-hostparent)); `&author=` names the user |
|
||||
|
||||
For example: `<iframe src="https://example.com/deckcraft/?webgl" ...>`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user