Merge the startup, clipboard, animation-preview and UI PRs integrated on land-r5 (#644 #652 #655 #702 #571 #539 #674 #648 #697 #479 #369 #368)

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/ribbon.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:58:48 -04:00
46 changed files with 3238 additions and 235 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox` (presentations open, media is inserted);
//! 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;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
+18 -5
View File
@@ -1,7 +1,7 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Inbox, Services, SlideApp};
use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
use wasm_bindgen::JsCast as _;
const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"];
@@ -77,16 +77,23 @@ impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
// Capture the document and slide now. The bytes arrive later, after the user may
// have switched slides.
let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes }));
ctx.request_repaint();
}
Err(e) => {
log::error!("couldn't read dropped file {name}: {e}");
push_inbox(&inbox, InboxEvent::Cancelled { id });
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -107,7 +114,7 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
@@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
push_inbox(&inbox, InboxEvent::Cancelled { id: request });
ctx.request_repaint();
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes }));
ctx.request_repaint();
});
})),
@@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
}
}
fn push_inbox(inbox: &Inbox, event: InboxEvent) {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event);
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
+74 -44
View File
@@ -1,6 +1,7 @@
//! DeckCraft desktop app.
//!
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
//!
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@@ -19,6 +20,8 @@ mod display;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(not(target_arch = "wasm32"))]
mod pasteboard;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
@@ -64,24 +67,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut SlideApp) {
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<deckcraft_ui_egui::UiState>(&bytes)
{
let Some(dir) = config_dir() else { return };
let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir);
if let Some(ui) = ui {
app.ui = ui;
}
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(prefs) = serde_json::from_slice::<deckcraft_engine::Prefs>(&bytes)
{
if let Some(prefs) = prefs {
app.session.prefs = prefs;
}
}
@@ -90,14 +85,8 @@ fn save_prefs(app: &SlideApp) {
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
let _ = std::fs::write(&p, bytes);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) {
let _ = std::fs::write(p.with_file_name("prefs.json"), bytes);
}
if let Some(dir) = config_dir() {
deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs);
}
}
@@ -124,14 +113,9 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_image: Some(Box::new(|| {
let mut cb = arboard::Clipboard::new().ok()?;
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
})),
clipboard_image: Some(Box::new(pasteboard::image_png)),
clipboard_html: Some(Box::new(pasteboard::html)),
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
.is_none()
@@ -142,6 +126,41 @@ fn services() -> Services {
const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\
Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
--control PORT JSON control server on 127.0.0.1:PORT
--sample open the sample presentation
--show start the slide show
--help, -h print this help and exit
--version, -V print the version and exit
";
enum Startup {
Help,
Version,
Run { control_port: Option<u16>, files: Vec<String>, sample: bool, show: bool },
}
/// `--help` and `--version` leave before the window and the log file.
fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>) -> Startup {
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" | "-V" => return Startup::Version,
"--help" | "-h" => return Startup::Help,
_ => files.push(a),
}
}
Startup::Run { control_port, files, sample, show }
}
fn app_icon() -> Option<egui::IconData> {
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
@@ -151,26 +170,21 @@ fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" => {
let (control_port, files, sample, show) =
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
Startup::Help => {
print!("{USAGE}");
return Ok(());
}
Startup::Version => {
println!("deckcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show),
};
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
// The log file lives under the settings directory; opened after the arguments, so `--help` and
// `--version` leave no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
@@ -279,4 +293,20 @@ mod tests {
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
#[test]
fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version));
let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]);
assert!(matches!(launch, Startup::Run { .. }));
if let Startup::Run { control_port, files, sample, show } = launch {
assert_eq!(control_port, Some(4242));
assert_eq!(files, ["talk.pptx".to_string()]);
assert!(sample);
assert!(!show);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard).
//!
//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain
//! words. A shape with no text is copied as an image by the UI; this module only reads a picture
//! back (Keynote's TIFF) as PNG bytes.
/// HTML currently on the pasteboard, when there is some.
pub fn html() -> Option<String> {
let mut cb = arboard::Clipboard::new().ok()?;
let html = cb.get().html().ok()?;
let html = html.trim();
if html.is_empty() { None } else { Some(html.to_string()) }
}
/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it.
pub fn set_html(html: &str, text: &str) -> bool {
let Ok(mut cb) = arboard::Clipboard::new() else { return false };
cb.set_html(html, Some(text)).is_ok()
}
/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard).
pub fn image_png() -> Option<Vec<u8>> {
let mut cb = arboard::Clipboard::new().ok()?;
let img = cb.get_image().ok()?;
let width = u32::try_from(img.width).ok()?;
let height = u32::try_from(img.height).ok()?;
let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
/// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back.
/// Restores whatever plain text was there. Other platforms return immediately.
#[test]
fn html_and_a_picture_round_trip_on_macos() {
if !cfg!(target_os = "macos") {
return;
}
let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());
let _restore = Restore(previous);
assert!(super::set_html("<b>DeckCraft</b>", "DeckCraft"), "set html");
let html = super::html().unwrap_or_default();
assert!(html.contains("<b>") || html.contains("<B>"), "{html}");
let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default();
assert_eq!(text, "DeckCraft");
let mut cb = arboard::Clipboard::new().expect("pasteboard");
let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) };
cb.set_image(img).expect("set image");
drop(cb);
let png = super::image_png().expect("png");
let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();
let p = decoded.get_pixel(0, 0);
assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}");
}
/// Puts the previous plain text back, including when the test fails halfway.
struct Restore(Option<String>);
impl Drop for Restore {
fn drop(&mut self) {
let Ok(mut cb) = arboard::Clipboard::new() else { return };
match self.0.take() {
Some(t) => {
let _ = cb.set_text(t);
}
None => {
let _ = cb.clear();
}
}
}
}
}