mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 03:49:34 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e2254d3d | ||
|
|
6bae03795f | ||
|
|
341a565048 | ||
|
|
0b7e1e3272 | ||
|
|
9bac60ff53 | ||
|
|
00ce289f3c | ||
|
|
86cd956454 | ||
|
|
d9102e1eb6 |
Generated
+37
-48
@@ -847,12 +847,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -875,7 +875,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -883,7 +883,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -892,7 +892,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -912,7 +912,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -935,21 +935,10 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1028,7 +1017,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1050,7 +1039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1059,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1068,14 +1057,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1085,14 +1074,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1118,7 +1107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1130,7 +1119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1141,7 +1130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1149,7 +1138,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1158,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1167,21 +1156,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1191,7 +1180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1209,7 +1198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1218,7 +1207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1230,15 +1219,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1256,7 +1245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1272,7 +1261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1291,7 +1280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1305,7 +1294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1331,7 +1320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1340,9 +1329,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -2791,7 +2779,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6325,7 +6313,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6571,6 +6559,7 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,11 +59,11 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -109,7 +109,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -16,10 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
|
||||
//! applying a command the page was told had failed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
state: Arc<AtomicU8>,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "the app dropped the request"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
ControlRequest::abandon(&self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use deckcraft_ui_egui::{ControlRequest, SlideApp};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{POLL_MS, Pending};
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "deckcraft";
|
||||
|
||||
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
|
||||
/// `window.deckcraft` keeps the functions it already has.
|
||||
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
app.control_rx = Some(requests);
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
|
||||
if queue.send(control).is_err() {
|
||||
pending.answered = true;
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = pending.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
|
||||
/// sender gave up.
|
||||
const TAKEN: u8 = 1;
|
||||
const ABANDONED: u8 = 2;
|
||||
|
||||
fn pending() -> (Pending, ControlRequest) {
|
||||
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
|
||||
(Pending { reply, state: control.state.clone(), answered: false }, control)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
|
||||
let (waiting, control) = pending();
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
|
||||
assert!(!control.take(), "the app must not run a request the page was told had failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answered_call_is_not_released() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert!(control.take());
|
||||
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert_eq!(waiting.poll(), None);
|
||||
drop(control);
|
||||
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -79,7 +79,6 @@ pub fn start() {
|
||||
{
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
crate::api::install(&mut app, cc.egui_ctx.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -51,9 +51,6 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
// The window is closing for good: a ready update's helper starts now and installs it once
|
||||
// the process has exited.
|
||||
self.0.start_pending_update();
|
||||
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||
|
||||
@@ -120,7 +120,13 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
let sb = f64_param(p, "spaceBefore");
|
||||
@@ -176,10 +182,10 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
|
||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" | "justifyLow" => Align::JustLow,
|
||||
"justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
|
||||
has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
@@ -556,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
|
||||
}
|
||||
|
||||
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
|
||||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
||||
let (st, f, l) = quick_style_for(i);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
|
||||
@@ -1713,31 +1713,6 @@ fn tab_moves_between_cells_skipping_merged_ones() {
|
||||
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_align_names_match_format_align() {
|
||||
use deckcraft_model::text::Align;
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.align;
|
||||
for (name, want) in [
|
||||
("justLow", Align::JustLow),
|
||||
("justifyLow", Align::JustLow),
|
||||
("thaiDist", Align::ThaiDistributed),
|
||||
("ctr", Align::Center),
|
||||
("centre", Align::Center),
|
||||
("r", Align::Right),
|
||||
("just", Align::Justify),
|
||||
("dist", Align::Distributed),
|
||||
("distributed", Align::Distributed),
|
||||
("left", Align::Left),
|
||||
] {
|
||||
s.execute("format.paragraph", &json!({"align": name})).unwrap();
|
||||
assert_eq!(para(&s), Some(want), "{name}");
|
||||
}
|
||||
assert!(s.execute("format.paragraph", &json!({"align": "bogus"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
@@ -7054,18 +7029,3 @@ fn outline_export_skips_footer_date_and_slide_number() {
|
||||
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
|
||||
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_adjust_and_quick_style_reject_invalid_index() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
let before = s.doc().unwrap().shape(id).unwrap().clone();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("shape.adjust", &json!({"index": bad, "value": 0.4})).is_err(), "adjust accepted {bad}");
|
||||
assert!(s.execute("shape.quickStyle", &json!({"index": bad})).is_err(), "quickStyle accepted {bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().geom, before.geom);
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().style, before.style);
|
||||
s.execute("shape.adjust", &json!({"value": 0.4})).unwrap();
|
||||
}
|
||||
|
||||
@@ -498,11 +498,6 @@ pub fn next_case(s: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `.` at `i` sits between two ASCII digits (a decimal point or version number).
|
||||
fn is_decimal_point(all: &[char], i: usize) -> bool {
|
||||
i > 0 && all[i - 1].is_ascii_digit() && all.get(i + 1).is_some_and(|n| n.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||
pub fn change_case(s: &str, mode: &str) -> String {
|
||||
change_case_parts(&[s], mode).concat()
|
||||
@@ -537,7 +532,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?') && !(c == '.' && is_decimal_point(&all, i))) {
|
||||
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
@@ -563,13 +558,6 @@ mod tests {
|
||||
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_decimal_point_is_not_a_boundary() {
|
||||
assert_eq!(change_case("I HAVE 3.5 APPLES", "sentence"), "I have 3.5 apples");
|
||||
assert_eq!(change_case_parts(&["V 1.", "2 BETA"], "sentence").concat(), "V 1.2 beta");
|
||||
assert_eq!(change_case("HELLO. WORLD", "sentence"), "Hello. World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_sigma_across_runs_is_not_final() {
|
||||
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
||||
|
||||
@@ -314,7 +314,6 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
compat_ln_spc: None,
|
||||
vert_overflow: None,
|
||||
horz_overflow: None,
|
||||
raw3d: None,
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
@@ -124,9 +124,6 @@ pub struct BodyProps {
|
||||
pub compat_ln_spc: Option<bool>,
|
||||
pub vert_overflow: Option<TextOverflow>,
|
||||
pub horz_overflow: Option<TextOverflow>,
|
||||
/// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw3d: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -190,9 +187,6 @@ impl BodyProps {
|
||||
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
|
||||
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
|
||||
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
|
||||
if self.raw3d.is_none() {
|
||||
self.raw3d.clone_from(&p.raw3d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -337,31 +337,16 @@ fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
|
||||
// Fit `y - b0 = (x / sx) * q(u)` with `u = (x - xm) / su` and `q` of degree `order - 1`. The
|
||||
// basis `(x / sx) * u^j` spans the same polynomials as `x^(j + 1)`, but keeps the pivots in
|
||||
// `solve` independent of the units and the offset of x.
|
||||
let n = xs.len() as f64;
|
||||
let sx = xs.iter().fold(0.0_f64, |acc, x| acc.max(x.abs()));
|
||||
if sx <= 0.0 || !sx.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n;
|
||||
let su = match xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())) {
|
||||
s if s > 0.0 && s.is_finite() => s,
|
||||
_ => 1.0,
|
||||
};
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let v = x / sx;
|
||||
let u = (x - xm) / su;
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = v * u.powi(i as i32);
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * v * u.powi(j as i32);
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,11 +355,10 @@ fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Opti
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect();
|
||||
let q = expand_shift(¢red, xm);
|
||||
let solved = solve(ata, atb)?;
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(q.iter().map(|c| c / sx));
|
||||
coeff.extend(solved);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
@@ -390,13 +374,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
||||
return polyfit_with_intercept(xs, ys, order, b0);
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n as f64;
|
||||
// Scale the centred x to unit range so the pivot threshold in `solve` does not depend on the
|
||||
// units of x.
|
||||
let s = xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs()));
|
||||
if s <= 0.0 || !s.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let us: Vec<f64> = xs.iter().map(|x| (x - xm) / s).collect();
|
||||
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
|
||||
let m = order + 1;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
@@ -415,7 +393,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect();
|
||||
let centred = solve(ata, atb)?;
|
||||
let coeff = expand_shift(¢red, xm);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
@@ -593,49 +571,6 @@ mod tests {
|
||||
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poly_fits_tiny_x_values() {
|
||||
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
|
||||
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
|
||||
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||
let fit = line(TrendlineType::Poly).fit(&xs, &ys).expect("poly fit of tiny x values");
|
||||
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
|
||||
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
|
||||
let scaled = line(TrendlineType::Poly).fit(&big, &ys).expect("poly fit of scaled x values");
|
||||
for x in xs {
|
||||
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poly_with_intercept_fits_tiny_x_values() {
|
||||
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
|
||||
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
|
||||
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||
let mut poly = line(TrendlineType::Poly);
|
||||
poly.intercept = Some(1.0);
|
||||
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of tiny x values");
|
||||
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
|
||||
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
|
||||
let scaled = poly.fit(&big, &ys).expect("poly fit with intercept of scaled x values");
|
||||
for x in xs {
|
||||
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poly_with_intercept_fits_offset_but_narrow_x_range() {
|
||||
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||
let f = |x: f64| 1.0 + x * (1.0 + 100.0 * (x - 45000.02));
|
||||
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||
let mut poly = line(TrendlineType::Poly);
|
||||
poly.intercept = Some(1.0);
|
||||
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of offset x values");
|
||||
for x in xs {
|
||||
assert!((fit.y(x).unwrap() - f(x)).abs() < 1e-2, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fits_offset_but_narrow_x_range() {
|
||||
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||
|
||||
@@ -629,19 +629,7 @@ pub fn list_style(imp: &mut Imp, part: &Part, ls: &El) -> ListStyle {
|
||||
}
|
||||
|
||||
pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
|
||||
let mut body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
|
||||
if let Some(b) = tx.child("bodyPr") {
|
||||
let mut raw3d = String::new();
|
||||
if let Some(sc) = b.child("scene3d") {
|
||||
raw3d.push_str(&part.keep_inner(sc));
|
||||
}
|
||||
if let Some(sp) = b.child("sp3d").or_else(|| b.child("flatTx")) {
|
||||
raw3d.push_str(&part.keep_inner(sp));
|
||||
}
|
||||
if !raw3d.is_empty() && raw3d.len() <= 64 * 1024 {
|
||||
body.raw3d = Some(raw3d);
|
||||
}
|
||||
}
|
||||
let body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
|
||||
let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default();
|
||||
let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
|
||||
TextBody { body, list_style, paragraphs }
|
||||
|
||||
@@ -395,12 +395,6 @@ fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Sha
|
||||
if let Some(fx) = pr.child("effectLst") {
|
||||
s.effects = Some(dml::effects(fx));
|
||||
}
|
||||
if let Some(sc) = pr.child("scene3d") {
|
||||
let raw = part.keep_inner(sc);
|
||||
if !raw.is_empty() && raw.len() <= 64 * 1024 {
|
||||
s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
let children = sp_tree(imp, part, ctx, e, depth + 1);
|
||||
let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default();
|
||||
|
||||
@@ -461,21 +461,6 @@ fn sound_label(name: &str) -> String {
|
||||
if label.is_empty() { "sound.wav".into() } else { label }
|
||||
}
|
||||
|
||||
/// Validate an unmodelled 3-D fragment (`a:scene3d`, `a:sp3d`, `a:flatTx`) before writing raw XML.
|
||||
pub fn valid_raw3d<'a>(raw: &'a str, allowed: &[&str]) -> Option<&'a str> {
|
||||
if raw.is_empty() || raw.len() > 64 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let wrap = format!("<r xmlns:a=\"{}\">{raw}</r>", crate::opc::NS_A);
|
||||
let doc = crate::xml::parse(wrap.as_bytes()).ok()?;
|
||||
let els: Vec<_> = doc.root.elements().collect();
|
||||
(!els.is_empty()
|
||||
&& els.len() <= allowed.len()
|
||||
&& doc.root.children.len() == els.len()
|
||||
&& els.iter().all(|e| e.prefix() == "a" && allowed.contains(&e.local()) && !crate::read::has_rel_refs(e)))
|
||||
.then_some(raw)
|
||||
}
|
||||
|
||||
pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
let a = A::new()
|
||||
.o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64))
|
||||
@@ -496,8 +481,7 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
|
||||
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
|
||||
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
let raw3d = b.raw3d.as_deref().and_then(|s| valid_raw3d(s, &["scene3d", "sp3d", "flatTx"]));
|
||||
if warp.is_none() && b.autofit.is_none() && raw3d.is_none() {
|
||||
if warp.is_none() && b.autofit.is_none() {
|
||||
w.empty("a:bodyPr", a);
|
||||
return;
|
||||
}
|
||||
@@ -531,9 +515,6 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(raw) = raw3d {
|
||||
w.raw(raw);
|
||||
}
|
||||
w.close("a:bodyPr");
|
||||
}
|
||||
|
||||
|
||||
@@ -334,20 +334,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
dml::fill(w, x, o, f);
|
||||
}
|
||||
if let Some(fx) = &s.effects {
|
||||
if fx.fill_overlay.is_some()
|
||||
|| fx.glow.is_some()
|
||||
|| fx.inner_shadow.is_some()
|
||||
|| fx.outer_shadow.is_some()
|
||||
|| fx.reflection.is_some()
|
||||
|| fx.soft_edge.is_some()
|
||||
{
|
||||
dml::effects(w, fx);
|
||||
}
|
||||
if let Some(raw) =
|
||||
fx.raw3d.as_deref().map(|s| s.find("<a:sp3d").map_or(s, |i| &s[..i])).and_then(|s| dml::valid_raw3d(s, &["scene3d"]))
|
||||
{
|
||||
w.raw(raw);
|
||||
}
|
||||
dml::effects(w, fx);
|
||||
}
|
||||
w.close("p:grpSpPr");
|
||||
shapes(w, x, o, cx, children, depth + 1);
|
||||
|
||||
@@ -241,67 +241,6 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) {
|
||||
assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_group_3d_survive() {
|
||||
let text_3d =
|
||||
"<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d extrusionH=\"12700\"/>";
|
||||
let flat_tx = "<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:flatTx z=\"0\"/>";
|
||||
let mut p3 = defaults::new_presentation(None);
|
||||
let mut sh = shape(&mut p3, "Text3D", 10.0, 10.0, 100.0, 40.0);
|
||||
sh.text = Some(TextBody {
|
||||
body: BodyProps { raw3d: Some(text_3d.into()), ..Default::default() },
|
||||
paragraphs: vec![Paragraph::new("3D Text")],
|
||||
..Default::default()
|
||||
});
|
||||
let mut sh_flat = shape(&mut p3, "FlatTx3D", 10.0, 55.0, 100.0, 40.0);
|
||||
sh_flat.text = Some(TextBody {
|
||||
body: BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() },
|
||||
paragraphs: vec![Paragraph::new("Flat Text")],
|
||||
..Default::default()
|
||||
});
|
||||
let grp_3d = "<a:scene3d><a:camera prst=\"isometricTopUp\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d>";
|
||||
let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 80.0);
|
||||
grp.effects = Some(Effects { raw3d: Some(grp_3d.into()), ..Default::default() });
|
||||
grp.kind = ShapeKind::Group { children: vec![sh, sh_flat], child: Xfrm::new(10.0, 10.0, 100.0, 80.0) };
|
||||
let layout = defaults::layout_of_kind(&p3, deckcraft_model::LayoutType::Blank).unwrap_or_default();
|
||||
let mut s3 = defaults::new_slide(&mut p3, layout).expect("slide");
|
||||
s3.shapes.push(grp);
|
||||
p3.slides.push(Arc::new(s3));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&p3).expect("export");
|
||||
let slide_xml = {
|
||||
use std::io::Read;
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
|
||||
let mut s = String::new();
|
||||
z.by_name("ppt/slides/slide2.xml").expect("slide2").read_to_string(&mut s).expect("utf8");
|
||||
s
|
||||
};
|
||||
assert!(!slide_xml.contains("<a:effectLst/>"), "Group3D with only raw3d must not emit empty effectLst: {slide_xml}");
|
||||
assert!(slide_xml.contains("<a:flatTx z=\"0\"/>"), "{slide_xml}");
|
||||
|
||||
let r3 = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let r3_twice = round(&r3);
|
||||
let g3 = by_name(&r3, "Group3D");
|
||||
let g3_raw = g3.effects.as_ref().and_then(|e| e.raw3d.as_deref()).unwrap_or("");
|
||||
let t3_raw = g3.children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
|
||||
let flat_raw = g3.children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
|
||||
assert!(g3_raw.contains("isometricTopUp"), "{g3_raw}");
|
||||
assert!(t3_raw.contains("orthographicFront") && t3_raw.contains("extrusionH=\"12700\""), "{t3_raw}");
|
||||
assert!(flat_raw.contains("flatTx"), "{flat_raw}");
|
||||
assert_eq!(by_name(&r3_twice, "Group3D").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(g3_raw));
|
||||
assert_eq!(by_name(&r3_twice, "Group3D").children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(t3_raw));
|
||||
assert_eq!(by_name(&r3_twice, "Group3D").children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(flat_raw));
|
||||
|
||||
// BodyProps::inherit propagates parent raw3d only when child raw3d is None.
|
||||
let parent_body = BodyProps { raw3d: Some(text_3d.into()), ..Default::default() };
|
||||
let mut child_body = BodyProps::default();
|
||||
child_body.inherit(&parent_body);
|
||||
assert_eq!(child_body.raw3d.as_deref(), Some(text_3d));
|
||||
let mut override_body = BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() };
|
||||
override_body.inherit(&parent_body);
|
||||
assert_eq!(override_body.raw3d.as_deref(), Some(flat_tx));
|
||||
}
|
||||
|
||||
fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) {
|
||||
let c = by_name(once, "Connector");
|
||||
assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind);
|
||||
|
||||
@@ -137,14 +137,6 @@ impl SlideApp {
|
||||
updates_ui::enable(self);
|
||||
}
|
||||
|
||||
/// Start the helper of an update that is ready, so that it installs once the process has
|
||||
/// exited. The desktop shell calls it in `on_exit`, which ends the process on macOS before the
|
||||
/// app is dropped. Later calls do nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn start_pending_update(&mut self) {
|
||||
updates_ui::start_pending_update(self);
|
||||
}
|
||||
|
||||
/// A message that must be seen wherever the user is: a toast, which also shows over the start
|
||||
/// screen and the File tab, where there is no status bar.
|
||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||
|
||||
@@ -13,11 +13,13 @@ pub(crate) struct Runtime {
|
||||
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
||||
enabled: bool,
|
||||
check_requested: bool,
|
||||
/// A manual check's result dialog is showing (checking, up to date or failed).
|
||||
result_open: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +31,10 @@ pub(crate) fn enable(app: &mut SlideApp) {
|
||||
pub(crate) fn request_check(app: &mut SlideApp) {
|
||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||
app.updates_rt.check_requested = true;
|
||||
app.updates_rt.result_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the helper of an update that is ready, so that it installs once the process has exited.
|
||||
/// Later calls do nothing.
|
||||
pub(crate) fn start_pending_update(app: &mut SlideApp) {
|
||||
app.updates_rt.updater.start_pending_update();
|
||||
}
|
||||
|
||||
pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let rt = &mut app.updates_rt;
|
||||
if std::mem::take(&mut rt.check_requested) {
|
||||
@@ -49,31 +46,21 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
// An installation starts, and a ready update asks the app to close, only while nothing is unsaved.
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// The app's close path asks about unsaved work; `on_exit` starts the restart helper.
|
||||
// The app's close path asks about unsaved work; the restart helper relaunches once the process exits.
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
// The result window reports a requested check, and at the first start after an update what
|
||||
// that update came to (installed, or why it failed).
|
||||
let rt = &mut app.updates_rt;
|
||||
if !matches!(rt.updater.status(), Status::Idle) {
|
||||
let response = CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status());
|
||||
answer(rt, response);
|
||||
}
|
||||
}
|
||||
|
||||
/// Acts on the result window's answer.
|
||||
fn answer(rt: &mut Runtime, response: CheckResultResponse) {
|
||||
match response {
|
||||
CheckResultResponse::Close => rt.updater.dismiss_status(),
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
// The next frame's `show_if` asks the window to close once nothing is unsaved.
|
||||
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
|
||||
CheckResultResponse::None => {}
|
||||
if rt.result_open {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.result_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,62 +77,4 @@ mod tests {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
assert!(app.updates_rt.updater.prompt_open());
|
||||
}
|
||||
|
||||
/// An app whose updater keeps its outcome records under `root`, reads no launcher choice and
|
||||
/// asks no server, with the check at start turned on.
|
||||
fn app_with_records(root: &std::path::Path) -> SlideApp {
|
||||
let mut app = SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default());
|
||||
app.updates_rt.updater = Updater::new(&crate::UPDATE_APP, VERSION)
|
||||
.with_paths(craft_update::Paths::under(root))
|
||||
.with_pins_path(root.join("pins.json"))
|
||||
.with_source(std::sync::Arc::new(|| Ok(None)));
|
||||
app.updates_rt.enabled = true;
|
||||
app
|
||||
}
|
||||
|
||||
fn records_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-updates-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// The first start after an update reports it; OK dismisses the report.
|
||||
#[test]
|
||||
fn the_start_after_an_update_reports_it_until_dismissed() {
|
||||
let root = records_dir("installed");
|
||||
std::fs::create_dir_all(root.join("data")).unwrap();
|
||||
std::fs::write(root.join("data").join("self-update-done.txt"), VERSION).unwrap();
|
||||
let mut app = app_with_records(&root);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
tick(&mut app, ui.ctx());
|
||||
show(&mut app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(matches!(app.updates_rt.updater.status(), Status::Updated(_)), "{:?}", app.updates_rt.updater.status());
|
||||
answer(&mut app.updates_rt, CheckResultResponse::Close);
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Idle);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The first start after a failed update says why; Check Again asks for a new check.
|
||||
#[test]
|
||||
fn the_start_after_a_failed_update_reports_why_and_offers_a_new_check() {
|
||||
let root = records_dir("failed");
|
||||
std::fs::create_dir_all(root.join("data").join("logs")).unwrap();
|
||||
std::fs::write(root.join("data").join("logs").join("self-update-error.txt"), "the disk is full").unwrap();
|
||||
let mut app = app_with_records(&root);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
tick(&mut app, ui.ctx());
|
||||
show(&mut app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::InstallFailed("the disk is full".into()));
|
||||
answer(&mut app.updates_rt, CheckResultResponse::CheckAgain);
|
||||
assert!(app.updates_rt.check_requested);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
||||
deckcraft_model::AnimStart::WithPrevious => "With Previous",
|
||||
deckcraft_model::AnimStart::AfterPrevious => "After Previous",
|
||||
});
|
||||
dropdown(ui, "Start", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
|
||||
dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
|
||||
for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "animation.timing", json!({"start": v}));
|
||||
|
||||
@@ -171,7 +171,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
let size = st.get("size").and_then(Value::as_f64);
|
||||
let fams = font_families(app);
|
||||
let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();
|
||||
dropdown(ui, "Font", &font, 150.0, en, |ui| {
|
||||
dropdown(ui, "font", &font, 150.0, en, |ui| {
|
||||
for (k, f) in fams.iter().enumerate() {
|
||||
let label = if k == 0 && theme_fonts.len() == 2 {
|
||||
format!("{f} (Headings)")
|
||||
@@ -196,7 +196,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
}
|
||||
});
|
||||
let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
|
||||
dropdown(ui, "Font size", &size_text, 52.0, en, |ui| {
|
||||
dropdown(ui, "size", &size_text, 52.0, en, |ui| {
|
||||
for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {
|
||||
if ui.button(widgets::fmt_num(*s, "")).clicked() {
|
||||
run(app, "format.size", json!({"size": s}));
|
||||
|
||||
@@ -1223,56 +1223,6 @@ fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
h
|
||||
}
|
||||
|
||||
/// #1855: text pasted into the focused speaker-notes field lands at its caret. While a stock
|
||||
/// field has the focus, `SlideApp::keyboard` leaves the batch to it and nothing in the app drops
|
||||
/// `Paste`, whether it arrives alone or inside the ⌘V batch egui-winit sends (V down, Paste, V up).
|
||||
#[test]
|
||||
fn paste_reaches_the_focused_notes_field() {
|
||||
let mut h = step_app(Services::default());
|
||||
let id = egui::Id::unique("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(id)), "the notes field has the focus");
|
||||
h.event(egui::Event::Text("ab".into()));
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::Paste("PASTED".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().notes_buf.2.text, "abPASTED", "a bare Paste event");
|
||||
let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
h.input_mut().events.extend([v(true), egui::Event::Paste("TWO".into()), v(false)]);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().notes_buf.2.text, "abPASTEDTWO", "the ⌘V batch");
|
||||
// Leaving the field commits the notes to the slide.
|
||||
h.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.slides[0].notes_text(), "abPASTEDTWO");
|
||||
}
|
||||
|
||||
/// #1956: the ribbon's drop-downs are announced by their purpose, not by their value or an
|
||||
/// internal key. Animations › Start with a shape that has no animation was "astart"; Home's Font
|
||||
/// and Font size boxes were "font" and "size" while nothing told them a font or size.
|
||||
#[test]
|
||||
fn ribbon_dropdowns_are_named_by_purpose_even_when_empty() {
|
||||
use egui::accesskit::Role;
|
||||
let mut h = step_app(Services::default());
|
||||
// A fresh shape, selected but not edited and without an animation.
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap();
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.state_mut().ui.tab = "Animations".into();
|
||||
h.run_steps(2);
|
||||
let start = h.get_by_role_and_label(Role::ComboBox, "Start");
|
||||
assert_eq!(start.value(), None, "no animation, no start value");
|
||||
assert!(h.query_by_label("astart").is_none(), "the internal key is not a name");
|
||||
|
||||
h.state_mut().ui.tab = "Home".into();
|
||||
h.state_mut().run("edit.select", json!({"ids": []})).unwrap();
|
||||
h.run_steps(2);
|
||||
for (name, key) in [("Font", "font"), ("Font size", "size")] {
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, name).is_some(), "{name} is named while empty");
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, key).is_none(), "{key} is not announced");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold.
|
||||
#[test]
|
||||
fn copy_offers_html_and_paste_keeps_bold() {
|
||||
|
||||
@@ -330,9 +330,7 @@ pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
}
|
||||
|
||||
/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
|
||||
/// `label` is what assistive technology calls it ("Font", "Start"); `current` is exposed as its
|
||||
/// value, so an empty box is still announced by its purpose and never by an internal key (#1956).
|
||||
pub fn dropdown(ui: &mut Ui, label: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
|
||||
pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
let painter = ui.painter_at(rect);
|
||||
@@ -352,10 +350,8 @@ pub fn dropdown(ui: &mut Ui, label: &str, current: &str, width: f32, enabled: bo
|
||||
if enabled { t.text } else { t.text_faint },
|
||||
);
|
||||
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo {
|
||||
current_text_value: (!current.is_empty()).then(|| current.to_string()),
|
||||
..egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, label)
|
||||
});
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current }));
|
||||
let _ = id;
|
||||
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
|
||||
});
|
||||
@@ -746,26 +742,19 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
|
||||
/// purpose with the shown text as its value. An empty one (no selection to read a font or
|
||||
/// size from) keeps its name rather than falling back to an internal key (#1956).
|
||||
/// current value (0.37's strict unnamed-input check would otherwise reject it).
|
||||
#[test]
|
||||
fn the_dropdown_is_named_by_its_purpose_and_carries_its_value() {
|
||||
fn the_dropdown_has_a_combo_box_role_and_a_name() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| {
|
||||
theme::install_fonts(ui.ctx());
|
||||
dropdown(ui, "Font size", "18 pt", 120.0, true, |ui| {
|
||||
dropdown(ui, "font-size", "18 pt", 120.0, true, |ui| {
|
||||
ui.label("12 pt");
|
||||
ui.label("18 pt");
|
||||
});
|
||||
dropdown(ui, "Start", "", 112.0, false, |_| {});
|
||||
});
|
||||
harness.run();
|
||||
let combo = harness.get_by_role_and_label(Role::ComboBox, "Font size");
|
||||
assert_eq!(combo.value().as_deref(), Some("18 pt"));
|
||||
let combo = harness.get_by_role_and_label(Role::ComboBox, "18 pt");
|
||||
assert!(combo.rect().width() > 100.0);
|
||||
assert!(harness.query_by_role_and_label(Role::ComboBox, "18 pt").is_none(), "the value is not its name");
|
||||
let empty = harness.get_by_role_and_label(Role::ComboBox, "Start");
|
||||
assert_eq!(empty.value(), None, "nothing shown, no value");
|
||||
assert!(harness.query_by_label("astart").is_none() && harness.query_by_label("start").is_none(), "no internal key is announced");
|
||||
}
|
||||
|
||||
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
|
||||
|
||||
@@ -84,7 +84,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow |
|
||||
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. `scene3d`, `sp3d` and `flatTx` on a text body (`a:bodyPr`) and `scene3d` on a group (`p:grpSpPr`) are kept as raw XML too. Bevels, cameras, face lighting and extrusion sides draw approximately |
|
||||
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately |
|
||||
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||
| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped |
|
||||
|
||||
@@ -136,18 +136,6 @@ beside it. The page guards leaving itself (DeckCraft adds no guard of its own),
|
||||
|
||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `deckcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. DeckCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Driving it from the page (`window.deckcraft`)
|
||||
|
||||
Every DeckCraft web build puts `window.deckcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `deckcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects.
|
||||
|
||||
```js
|
||||
const deck = await deckcraft.docInspect(); // a summary of the open presentation
|
||||
await deckcraft.execute("shape.insert", { preset: "roundRect", rect: [100, 100, 240, 120], text: "Hello" });
|
||||
const { data } = await deckcraft.screenshot(); // the canvas as PNG (base64)
|
||||
```
|
||||
|
||||
The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
@@ -6,8 +6,7 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SHARED_REPO: &str = "https://github.com/storytold/craft-libs";
|
||||
const SHARED_REV: &str = "1b34eba41e62138fc2438d2734523b302840b45d";
|
||||
const SHARED_REV: &str = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7";
|
||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
@@ -71,7 +70,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
||||
}
|
||||
matches.first().copied().ok_or_else(|| format!("missing {name}"))
|
||||
};
|
||||
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
for (name, version, expected_source, expected_features) in [
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
|
||||
@@ -144,7 +143,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn metadata() -> Value {
|
||||
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
json!({
|
||||
"packages": [
|
||||
{"id":"app", "name":"deckcraft-pdf"},
|
||||
|
||||
Reference in New Issue
Block a user