mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 14:07:06 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user