mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-05 01:49:41 -06:00
Compare commits
54
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 | ||
|
|
52f87aca13 | ||
|
|
dfee4c1e88 | ||
|
|
f321416b34 | ||
|
|
41f07a3c4e | ||
|
|
85de6ca5d7 | ||
|
|
fc18a78e65 | ||
|
|
31abd2d819 | ||
|
|
144fd28f69 | ||
|
|
65ddd8ad51 | ||
|
|
9d36b5931c | ||
|
|
c252a681a0 | ||
|
|
80f784d210 | ||
|
|
07dc2210a1 | ||
|
|
51d19baa0c | ||
|
|
50187449c2 | ||
|
|
b8de82e2cc | ||
|
|
6eaae2bd8c | ||
|
|
d5471bccf0 | ||
|
|
b9eaaeb23b | ||
|
|
74d7b844fe | ||
|
|
6303166a18 | ||
|
|
d8936f94af | ||
|
|
813378cc20 | ||
|
|
cc6773c95f |
@@ -45,3 +45,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
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"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
**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)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -150,6 +153,8 @@ 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 |
|
||||
@@ -330,8 +335,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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() {
|
||||
|
||||
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
|
||||
let cmd = if forward { "comment.next" } else { "comment.prev" };
|
||||
let (threads, slide, index) = {
|
||||
let st = s.doc()?;
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
|
||||
};
|
||||
if threads.is_empty() {
|
||||
return Err(bad(cmd, "no comments"));
|
||||
@@ -862,6 +862,40 @@ mod tests {
|
||||
assert_eq!(here["text"], "on first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_comment_navigation_index_does_not_change_selection() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("comment.add", &json!({"text": "first"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "second"})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
|
||||
s.doc_mut().unwrap().selection.shapes = vec![shape];
|
||||
|
||||
for (command, value) in [
|
||||
("comment.next", json!(-1)),
|
||||
("comment.next", json!(1.5)),
|
||||
("comment.next", json!("bad")),
|
||||
("comment.prev", json!(-1)),
|
||||
("comment.prev", json!(1.5)),
|
||||
("comment.prev", json!("bad")),
|
||||
] {
|
||||
let before = s.doc().unwrap().selection.clone();
|
||||
let error = s.execute(command, &json!({"index": value})).unwrap_err();
|
||||
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
|
||||
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
|
||||
}
|
||||
|
||||
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
|
||||
assert_eq!(zero["slide"], 1);
|
||||
assert_eq!(zero["index"], 0);
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let omitted = s.execute("comment.next", &json!({})).unwrap();
|
||||
assert_eq!(omitted["slide"], 0);
|
||||
assert_eq!(omitted["index"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_iso_utc() {
|
||||
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
has_selection,
|
||||
fill
|
||||
),
|
||||
|
||||
@@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
|
||||
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
|
||||
/// rotateWithShape?}`.
|
||||
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
|
||||
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
|
||||
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
let stops: Vec<GradientStop> = v
|
||||
.get("stops")?
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -4321,6 +4321,9 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
|
||||
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
|
||||
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
|
||||
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
|
||||
// The renderer keeps only linear and radial gradients upright (`paint::fill_path`).
|
||||
let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about;
|
||||
assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}");
|
||||
for kind in ["linear", "radial", "rectangular", "path"] {
|
||||
assert!(about.contains(kind), "{kind}: {about}");
|
||||
let g = cmd::shape::gradient_param(&json!({
|
||||
@@ -6506,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] {
|
||||
|
||||
@@ -1260,9 +1260,8 @@ impl FontDb {
|
||||
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
let open = style.find('{')?;
|
||||
let base = self.find(family, style[..open].trim())?;
|
||||
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
|
||||
return Some(base);
|
||||
}
|
||||
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
|
||||
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
|
||||
let mut coords = base.coords.clone();
|
||||
for kv in style[open + 1..].trim_end_matches('}').split(',') {
|
||||
let (k, v) = kv.split_once(':')?;
|
||||
@@ -1272,6 +1271,11 @@ impl FontDb {
|
||||
return None;
|
||||
}
|
||||
let tag = [k[0], k[1], k[2], k[3]];
|
||||
// A tag the font has no axis for changes nothing; keeping it would grow the list with
|
||||
// every distinct tag in the style text.
|
||||
if !axis_tags.contains(&tag) {
|
||||
continue;
|
||||
}
|
||||
match coords.iter_mut().find(|(t, _)| *t == tag) {
|
||||
Some(c) => c.1 = v,
|
||||
None => coords.push((tag, v)),
|
||||
|
||||
@@ -247,6 +247,11 @@ pub fn repair(p: &mut Presentation) {
|
||||
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
|
||||
p.notes_size = Presentation::default().notes_size;
|
||||
}
|
||||
// A notes page is bounded like a slide: canvas and handout drawing scale with it.
|
||||
p.notes_size = deckcraft_model::Size::new(
|
||||
p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
|
||||
p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
|
||||
);
|
||||
sane_numbers(p);
|
||||
}
|
||||
|
||||
@@ -689,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 };
|
||||
@@ -299,7 +300,8 @@ impl Outline<'_> {
|
||||
|
||||
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
|
||||
fn symbol(&mut self, n: u8) {
|
||||
self.i += 1;
|
||||
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
|
||||
self.i = advance(self.rtf, self.i, 1);
|
||||
if self.st.skip {
|
||||
return;
|
||||
}
|
||||
@@ -308,6 +310,7 @@ impl Outline<'_> {
|
||||
b'~' => self.line.push('\u{a0}'),
|
||||
b'_' => self.line.push('\u{2011}'),
|
||||
b'\n' | b'\r' => self.flush(),
|
||||
_ if !n.is_ascii() => self.line.extend(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +422,11 @@ mod tests {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_control_symbol_keeps_character_and_following_text() {
|
||||
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
|
||||
}
|
||||
|
||||
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
|
||||
#[test]
|
||||
fn non_breaking_hyphen_is_kept() {
|
||||
|
||||
@@ -451,6 +451,13 @@ fn degenerate_slide_sizes_are_repaired() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_bounds_the_notes_page_size() {
|
||||
let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() };
|
||||
repair(&mut p);
|
||||
assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0));
|
||||
}
|
||||
|
||||
/// A file whose slide size is null, a string or missing a side (written by hand or by
|
||||
/// another tool; NaN is also saved as null) opens with the default size rather than failing
|
||||
/// as damaged.
|
||||
|
||||
@@ -415,6 +415,11 @@ impl CaptureScanner {
|
||||
}
|
||||
|
||||
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
|
||||
// Ranges come from a scan of some source; check them against this one before sizing a buffer.
|
||||
let len = src.len();
|
||||
if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) {
|
||||
return Err(Error::Invalid("packet range lies outside the source".into()));
|
||||
}
|
||||
let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum());
|
||||
for &(o, n) in parts {
|
||||
let a = v.len();
|
||||
@@ -508,6 +513,7 @@ impl OpusTiming {
|
||||
c = timing_add(c, i64::from(durations[k]))?;
|
||||
ends[k] = Some(c);
|
||||
}
|
||||
cursor = Some(c);
|
||||
break;
|
||||
}
|
||||
let Some(g) = stream.packets[j].granule else { break };
|
||||
|
||||
@@ -183,6 +183,27 @@ fn opus_toc_durations() {
|
||||
assert_eq!(opus_packet_samples([0xF8, 0], 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_timing_includes_packets_after_the_last_known_granule() {
|
||||
let f = opus_file(6, 312, 0);
|
||||
let o = open(&f).unwrap();
|
||||
let mut stream = o.streams[0].clone();
|
||||
// Missing granules on complete-packet pages are non-conforming. The existing recovery
|
||||
// fallback still assigns their packet positions; total must include the same samples.
|
||||
stream.packets.last_mut().unwrap().granule = None;
|
||||
let timing = OpusTiming::of(&stream, 312).unwrap();
|
||||
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
|
||||
assert_eq!(timing.total, 6 * 960 - 312);
|
||||
|
||||
// With no usable granule, every complete packet still contributes its TOC duration.
|
||||
for packet in &mut stream.packets {
|
||||
packet.granule = None;
|
||||
}
|
||||
let timing = OpusTiming::of(&stream, 312).unwrap();
|
||||
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
|
||||
assert_eq!(timing.total, 6 * 960 - 312);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_timing_rejects_a_granule_that_overflows() {
|
||||
let packet = |granule: i64| Stream {
|
||||
@@ -274,3 +295,13 @@ fn a_budget_too_small_to_search_is_spent() {
|
||||
let f = opus_file(6, 312, 0);
|
||||
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_packet_checks_its_ranges_against_the_source_before_allocating() {
|
||||
let f = opus_file(10, 312, 500);
|
||||
let o = open(&f).unwrap();
|
||||
let s = o.stream_of(Codec::Opus).unwrap();
|
||||
// The last packet lies past the end of a source cut short: an error, not a read of missing bytes.
|
||||
let short = &f[..f.len() / 2];
|
||||
assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
@@ -522,7 +522,8 @@ impl Exporter<'_> {
|
||||
let k = b.width() / sw;
|
||||
// Raster at the requested dpi relative to the box size on paper.
|
||||
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
|
||||
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
|
||||
// One clock reading for the raster and the text layer, so a date field cannot differ between them.
|
||||
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
|
||||
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
|
||||
// on every slide that shares its layout, so the writer stores it once, and the slide's own
|
||||
// shapes draw over it (#845).
|
||||
@@ -563,7 +564,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
if self.opts.text_layer {
|
||||
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
|
||||
for pt in deckcraft_render::place_slide(pres, i).texts {
|
||||
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
|
||||
self.text_layer(s, &pt, view, false)?;
|
||||
}
|
||||
let _ = sh;
|
||||
@@ -581,7 +582,7 @@ impl Exporter<'_> {
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
|
||||
+56
-5
@@ -269,8 +269,6 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let text = "Café Ελληνικά Привет";
|
||||
let face = FontDb::global().face("Ubuntu", "Regular");
|
||||
@@ -292,8 +290,17 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
|
||||
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
assert_eq!(page_text(&bytes, 0), text);
|
||||
}
|
||||
|
||||
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
|
||||
/// operators decoded through the page font's ToUnicode map.
|
||||
fn page_text(bytes: &[u8], index: usize) -> String {
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
|
||||
let page = &pdf.pages()[index];
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
@@ -344,7 +351,51 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(extracted, text);
|
||||
extracted
|
||||
}
|
||||
|
||||
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
|
||||
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
|
||||
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
|
||||
for run in &mut body.paragraphs[0].runs {
|
||||
run.props.font = Some("Ubuntu".into());
|
||||
run.props.size = Some(20.0);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
let mut p = deck(n);
|
||||
for slide in &mut p.slides {
|
||||
Arc::make_mut(slide).shapes =
|
||||
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn slide_number_field_is_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::text::{Run, RunKind};
|
||||
let mut field = Run::new("<#>");
|
||||
field.kind = RunKind::Field { field: "slidenum".into() };
|
||||
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
|
||||
assert_eq!(page_text(&bytes, i), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
|
||||
use deckcraft_model::text::{Run, RunKind};
|
||||
let mut brk = Run::new("");
|
||||
brk.kind = RunKind::Break;
|
||||
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
assert_eq!(page_text(&bytes, 0), "onetwo");
|
||||
}
|
||||
|
||||
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
|
||||
|
||||
@@ -1351,6 +1351,7 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
|
||||
without_narration: sp.bool("showNarration") == Some(false),
|
||||
without_animation: sp.bool("showAnimation") == Some(false),
|
||||
use_timings: sp.bool("useTimings").unwrap_or(true),
|
||||
show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true),
|
||||
..Default::default()
|
||||
};
|
||||
if sp.child("browse").is_some() {
|
||||
|
||||
@@ -1206,6 +1206,14 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
|
||||
w.open0("p:penClr");
|
||||
w.val("a:srgbClr", s.pen_color.hex());
|
||||
w.close("p:penClr");
|
||||
if !s.show_media_controls {
|
||||
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
|
||||
w.open0("p:extLst");
|
||||
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
|
||||
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
|
||||
w.close("p:ext");
|
||||
w.close("p:extLst");
|
||||
}
|
||||
w.close("p:showPr");
|
||||
w.close("p:presentationPr");
|
||||
w.finish()
|
||||
|
||||
@@ -2258,6 +2258,34 @@ fn default_durations_match_powerpoint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
|
||||
/// written there, under its extension uri and with the p14 namespace declared on the element;
|
||||
/// on (the default) writes nothing.
|
||||
#[test]
|
||||
fn hidden_media_controls_survive_save_and_reopen() {
|
||||
let props = |p: &Presentation| {
|
||||
let bytes = deckcraft_pptx::export(p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut x = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
|
||||
x
|
||||
};
|
||||
let mut p = Presentation::default();
|
||||
p.show.show_media_controls = false;
|
||||
let xml = props(&p);
|
||||
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
|
||||
assert!(
|
||||
show.contains(
|
||||
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(!round(&p).show.show_media_controls);
|
||||
let default = Presentation::default();
|
||||
assert!(!props(&default).contains("showMediaCtrls"));
|
||||
assert!(round(&default).show.show_media_controls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
|
||||
let mut p = Presentation::default();
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
|
||||
pub use table::cell_body as table_cell_body;
|
||||
pub use table::cell_rects as table_cell_rects;
|
||||
pub use table::row_heights as table_row_heights;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! that put real text over a raster (PDF text layer, links) and for text extraction.
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Hyperlink, TextBody};
|
||||
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use deckcraft_text::{Opts, TextLayout};
|
||||
use deckcraft_text::{Fields, Opts, TextLayout};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
||||
use crate::{SlideFields, shape_geometry, table};
|
||||
@@ -52,10 +52,16 @@ pub struct Placed {
|
||||
|
||||
/// Text and links of slide `index` (master and layout graphics first, as rendered).
|
||||
pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
|
||||
place_slide_with(pres, index, &crate::RenderOpts::default())
|
||||
}
|
||||
|
||||
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
|
||||
/// date and time as the raster (`opts.now`).
|
||||
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
|
||||
let mut out = Placed::default();
|
||||
let Some(slide) = pres.slides.get(index) else { return out };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
@@ -154,9 +160,24 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = crate::text_transform(m, tr, layout.rotation);
|
||||
// Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that
|
||||
// goes with it (character offsets, text layer) must hold the same values.
|
||||
let mut placed = body.clone();
|
||||
for (pi, para) in body.paragraphs.iter().enumerate() {
|
||||
for (ri, run) in para.runs.iter().enumerate() {
|
||||
let RunKind::Field { field } = &run.kind else { continue };
|
||||
let lang = resolve::run(rctx, s, para, &run.props).lang;
|
||||
if let Some(v) = fields.field_in(field, lang.as_deref())
|
||||
&& let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri))
|
||||
{
|
||||
out_run.text = v;
|
||||
out_run.kind = RunKind::Text;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
// The run's visual extent (right-to-left runs run from right to left).
|
||||
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
@@ -164,7 +185,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
out.texts.push(PlacedText { layout, transform: m, body: placed });
|
||||
}
|
||||
|
||||
/// The hyperlink of the model run containing character `at` of paragraph `para`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
|
||||
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
|
||||
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
};
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
@@ -2971,6 +2971,27 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
|
||||
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
|
||||
let second = p.slides[0].clone();
|
||||
p.slides.push(second);
|
||||
let placed = place_slide(&p, 1);
|
||||
assert_eq!(placed.texts.len(), 1);
|
||||
assert_eq!(placed.texts[0].body.text(), "2");
|
||||
// The layout was drawn from the same value, so the offsets agree with the body.
|
||||
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_text_takes_its_date_from_the_given_render_options() {
|
||||
use deckcraft_text::datetime::DateTime;
|
||||
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
|
||||
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
|
||||
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
|
||||
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
|
||||
}
|
||||
|
||||
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
|
||||
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
|
||||
/// there), 595959 grey on Harbor's light ones.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit;
|
||||
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
|
||||
use egui::text::{Galley, LayoutJob, TextFormat};
|
||||
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
|
||||
use egui::{Align2, Color32, Stroke, Ui, vec2};
|
||||
|
||||
use crate::theme;
|
||||
@@ -210,30 +210,16 @@ pub fn editor(
|
||||
out
|
||||
}
|
||||
|
||||
/// The (min y, max y) of the row holding char index `at`; `walk` (row index, chars through that
|
||||
/// row) carries over between calls, so successive non-decreasing `at`s cost one pass in all.
|
||||
fn row_span_at(galley: &Galley, walk: &mut (usize, usize), at: usize) -> (f32, f32) {
|
||||
while walk.1 <= at && walk.0 + 1 < galley.rows.len() {
|
||||
walk.0 += 1;
|
||||
walk.1 += galley.rows[walk.0].char_count_including_newline().0;
|
||||
}
|
||||
galley.rows.get(walk.0).map_or((0.0, 0.0), |r| (r.min_y(), r.max_y()))
|
||||
}
|
||||
|
||||
/// Bullets and numbers at the start of each paragraph's first line of `galley` (laid out from
|
||||
/// `body`), drawn at `origin`.
|
||||
fn paint_bullets(painter: &egui::Painter, body: &TextBody, galley: &Galley, origin: egui::Pos2, size: f32, color: Color32) {
|
||||
let k = size / 12.0;
|
||||
let mut at = 0usize;
|
||||
// Paragraph starts only grow, so one forward walk over the rows finds each start's row
|
||||
// (`pos_from_cursor` would rescan from the top for every bullet).
|
||||
let mut walk = (0usize, galley.rows.first().map_or(0, |r| r.char_count_including_newline().0));
|
||||
for (p, bullet) in body.paragraphs.iter().zip(bullets(body)) {
|
||||
if let Some(b) = bullet {
|
||||
let (min_y, max_y) = row_span_at(galley, &mut walk, at);
|
||||
let (center_y, height) = ((min_y + max_y) / 2.0, max_y - min_y);
|
||||
let pos = origin + vec2(px(indents(p).0, k), center_y);
|
||||
let font = theme::font(height.clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
let row = galley.pos_from_cursor(CCursor::new(at));
|
||||
let pos = origin + vec2(px(indents(p).0, k), row.center().y);
|
||||
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
|
||||
}
|
||||
at += p.char_len() + 1;
|
||||
@@ -255,7 +241,6 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_model::text::{ParaProps, Run};
|
||||
use egui::text::CCursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -332,23 +317,6 @@ mod tests {
|
||||
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_walk_matches_cursor_lookup_at_paragraph_starts() {
|
||||
let ctx = egui::Context::default();
|
||||
let body = notes();
|
||||
let mut laid = None;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
|
||||
out.textures_delta.clear();
|
||||
let g = laid.unwrap();
|
||||
let mut walk = (0usize, g.rows.first().map_or(0, |r| r.char_count_including_newline().0));
|
||||
let mut at = 0usize;
|
||||
for p in &body.paragraphs {
|
||||
let want = g.pos_from_cursor(CCursor::new(at));
|
||||
assert_eq!(row_span_at(&g, &mut walk, at), (want.min.y, want.max.y), "paragraph at char {at}");
|
||||
at += p.char_len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_keeps_the_formatting_around_it() {
|
||||
let mut d = Draft::new(¬es());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1173,6 +1177,39 @@ 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);
|
||||
}
|
||||
// #1864: the web popup's Previous/Next buttons send the DOM key names.
|
||||
for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] {
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{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() {
|
||||
@@ -94,11 +98,20 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
show.zoom_arm = false;
|
||||
}
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
|
||||
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" ...>`.
|
||||
|
||||
|
||||
+7
-1
@@ -18,7 +18,13 @@ fn walk(dir: &Path, c: &mut Counts) {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if p.is_dir() {
|
||||
// `file_type` does not follow links, so a directory link is not walked (and aliases are
|
||||
// not counted twice).
|
||||
let Ok(kind) = e.file_type() else { continue };
|
||||
if kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
|
||||
Reference in New Issue
Block a user