mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -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:
@@ -154,7 +154,7 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
|
||||
DeckCraft is in **alpha**. It covers **80% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
|
||||
+18
-1
@@ -33,6 +33,10 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: copy and paste rich text and pictures with other apps; previous: hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (animation preview on the canvas; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Shape fill transparency follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: web file picker keeps its document and slide, #414; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -45,6 +49,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 39%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| **Feature breadth** | **80%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
@@ -75,7 +80,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
|
||||
|
||||
| Dimension | Ready | Hours to full | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Features (breadth 80% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
|
||||
@@ -107,6 +112,8 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
|
||||
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 68% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
@@ -209,6 +216,12 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` |
|
||||
| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% |
|
||||
| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
|
||||
| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) |
|
||||
| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Breadth 79% → 80%, Animations 79% → 85% |
|
||||
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency |
|
||||
| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -275,6 +288,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | trivial | Progress log: Design ▸ Effects |
|
||||
| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
|
||||
| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
|
||||
| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% |
|
||||
| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection |
|
||||
| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -13,9 +14,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
|
||||
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
["Home", "Clipboard"],
|
||||
Some("Cmd+V"),
|
||||
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special",
|
||||
@@ -134,15 +143,41 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
fn copying_caret(s: &Session) -> bool {
|
||||
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A caret copies nothing; the previous clipboard must not leak into this answer.
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
Ok(json!({"text": t}))
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
|
||||
fn copied_html(s: &Session) -> String {
|
||||
if let Some(body) = &s.clipboard.text {
|
||||
return richclip::body_to_html(body);
|
||||
}
|
||||
let mut paragraphs = Vec::new();
|
||||
for sh in &s.clipboard.shapes {
|
||||
if let Some(t) = &sh.text {
|
||||
paragraphs.extend(t.paragraphs.clone());
|
||||
}
|
||||
}
|
||||
if paragraphs.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
delete(s, p)?;
|
||||
Ok(json!({"text": t}))
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
|
||||
@@ -249,9 +284,16 @@ fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// External plain text (from the system clipboard) wins when given and differs from ours.
|
||||
// HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
|
||||
if let Some(t) = str_param(p, "text")
|
||||
&& t != s.clipboard.plain
|
||||
{
|
||||
if let Some(html) = str_param(p, "html")
|
||||
&& let Some(body) = richclip::html_to_body(html)
|
||||
&& richclip::same_words(&body.text(), t)
|
||||
{
|
||||
return paste_external_body(s, &body);
|
||||
}
|
||||
return paste_plain(s, t);
|
||||
}
|
||||
let editing = s.doc()?.selection.text.is_some();
|
||||
@@ -328,6 +370,33 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::paste_body(s, body);
|
||||
}
|
||||
let plain = body.text();
|
||||
let st = s.doc()?;
|
||||
let size = st.doc.slide_size;
|
||||
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
|
||||
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
|
||||
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
|
||||
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
|
||||
let body = body.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
|
||||
*t = body.clone();
|
||||
t.body.wrap = wrap;
|
||||
t.body.autofit = autofit;
|
||||
}
|
||||
sel.shapes = vec![ShapeId(id)];
|
||||
sel.text = None;
|
||||
Ok(json!({"id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::insert_text(s, text, None);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod picture_style;
|
||||
pub mod points;
|
||||
pub mod recolor;
|
||||
pub mod review;
|
||||
mod richclip;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Rich text on the system clipboard, as HTML.
|
||||
//!
|
||||
//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
const MAX_PARAS: usize = 2_000;
|
||||
const MAX_RUNS: usize = 10_000;
|
||||
|
||||
/// HTML for `body`: one `<p>` per paragraph, `<b>`, `<i>` and `<u>` for the formatting.
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
out.push_str(&open);
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
}
|
||||
out.push_str(&close);
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
if props.bold == Some(true) {
|
||||
open.push_str("<b>");
|
||||
close.insert_str(0, "</b>");
|
||||
}
|
||||
if props.italic == Some(true) {
|
||||
open.push_str("<i>");
|
||||
close.insert_str(0, "</i>");
|
||||
}
|
||||
if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) {
|
||||
open.push_str("<u>");
|
||||
close.insert_str(0, "</u>");
|
||||
}
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
/// says bold, italic or underline. Other tags are ignored. The result is capped.
|
||||
pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
if html.len() > MAX_HTML {
|
||||
return None;
|
||||
}
|
||||
let bytes = html.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS {
|
||||
if bytes[i] == b'<' {
|
||||
let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len());
|
||||
let raw = &html[i + 1..end];
|
||||
i = (end + 1).min(bytes.len());
|
||||
let token = raw.trim();
|
||||
if token.starts_with('!') || token.starts_with('?') {
|
||||
continue;
|
||||
}
|
||||
let closing = token.starts_with('/');
|
||||
let name_src = token.trim_start_matches('/').trim();
|
||||
let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len());
|
||||
let name = name_src[..name_end].to_ascii_lowercase();
|
||||
if name == "script" || name == "style" || name == "head" {
|
||||
if closing {
|
||||
skip = (skip - 1).max(0);
|
||||
} else if !token.ends_with('/') {
|
||||
skip += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
}
|
||||
let (db, di, du) = tag_style(&name, name_src);
|
||||
if db + di + du > 0 {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
|
||||
let text = decode_text(&html[i..end]);
|
||||
i = end;
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0);
|
||||
}
|
||||
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
|
||||
paragraphs.pop();
|
||||
}
|
||||
if paragraphs.iter().all(Paragraph::is_empty) {
|
||||
return None;
|
||||
}
|
||||
for p in &mut paragraphs {
|
||||
p.normalize();
|
||||
}
|
||||
Some(TextBody { paragraphs, ..Default::default() })
|
||||
}
|
||||
|
||||
fn tag_kind(name: &str) -> &'static str {
|
||||
match name {
|
||||
"b" | "strong" => "b",
|
||||
"i" | "em" => "i",
|
||||
"u" => "u",
|
||||
"span" | "font" => "span",
|
||||
"p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_break(name: &str) -> bool {
|
||||
name == "br" || name == "hr"
|
||||
}
|
||||
|
||||
fn is_block(name: &str) -> bool {
|
||||
matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote")
|
||||
}
|
||||
|
||||
fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
let mut bold = i32::from(name == "b" || name == "strong");
|
||||
let mut italic = i32::from(name == "i" || name == "em");
|
||||
let mut underline = i32::from(name == "u");
|
||||
if let Some(style) = attr(src, "style") {
|
||||
let s = style.to_ascii_lowercase();
|
||||
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
|
||||
bold = 1;
|
||||
}
|
||||
if s.contains("font-style:italic") || s.contains("font-style: italic") {
|
||||
italic = 1;
|
||||
}
|
||||
if s.contains("text-decoration:underline")
|
||||
|| s.contains("text-decoration: underline")
|
||||
|| s.contains("text-decoration-line:underline")
|
||||
|| s.contains("text-decoration-line: underline")
|
||||
{
|
||||
underline = 1;
|
||||
}
|
||||
}
|
||||
(bold, italic, underline)
|
||||
}
|
||||
|
||||
fn weight_bold(style: &str) -> bool {
|
||||
let Some(at) = style.find("font-weight") else { return false };
|
||||
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
|
||||
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
|
||||
}
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
let at = lower.find(key)?;
|
||||
let after = tag.get(at + key.len()..)?.trim_start();
|
||||
let after = after.strip_prefix('=')?.trim_start();
|
||||
let quote = after.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = after.get(quote.len_utf8()..)?;
|
||||
let end = rest.find(quote)?;
|
||||
rest.get(..end)
|
||||
}
|
||||
|
||||
fn new_para(paragraphs: &mut Vec<Paragraph>) {
|
||||
if paragraphs.len() >= MAX_PARAS {
|
||||
return;
|
||||
}
|
||||
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
|
||||
paragraphs.push(Paragraph::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
|
||||
if *runs >= MAX_RUNS {
|
||||
return;
|
||||
}
|
||||
let Some(p) = paragraphs.last_mut() else { return };
|
||||
let mut props = RunProps::default();
|
||||
if bold {
|
||||
props.bold = Some(true);
|
||||
}
|
||||
if italic {
|
||||
props.italic = Some(true);
|
||||
}
|
||||
if underline {
|
||||
props.underline = Some("sng".into());
|
||||
}
|
||||
p.runs.push(Run::with(text, props));
|
||||
*runs += 1;
|
||||
}
|
||||
|
||||
fn decode_text(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = raw;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
|
||||
rest = &rest[at..];
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
break;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
rest = &rest[end + 1..];
|
||||
match entity {
|
||||
"amp" => out.push('&'),
|
||||
"lt" => out.push('<'),
|
||||
"gt" => out.push('>'),
|
||||
"quot" => out.push('"'),
|
||||
"apos" => out.push('\''),
|
||||
"nbsp" => out.push(' '),
|
||||
_ => {
|
||||
let ch = entity.strip_prefix('#').and_then(numeric_char);
|
||||
if let Some(c) = ch {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
/// Do the HTML's words and the paste's plain text say the same thing?
|
||||
pub fn same_words(html_plain: &str, pasted: &str) -> bool {
|
||||
let a = fold(html_plain);
|
||||
let b = fold(pasted);
|
||||
a == b
|
||||
}
|
||||
|
||||
fn fold(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
body.paragraphs[0].runs = vec![
|
||||
Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }),
|
||||
Run::new(" "),
|
||||
Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }),
|
||||
];
|
||||
let html = body_to_html(&body);
|
||||
assert!(html.contains("<b>Hi</b>"), "{html}");
|
||||
assert!(html.contains("<i><u>there</u></i>") || html.contains("<u><i>there</i></u>"), "{html}");
|
||||
let back = html_to_body(&html).unwrap();
|
||||
assert_eq!(back.text(), "Hi there");
|
||||
assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
let last = back.paragraphs[0].runs.last().unwrap();
|
||||
assert_eq!(last.props.italic, Some(true));
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_style_and_escapes() {
|
||||
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A & B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
|
||||
let body = html_to_body(html).unwrap();
|
||||
assert_eq!(body.text(), "A & B\nC");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true));
|
||||
assert!(same_words(&body.text(), "A & B\nC"));
|
||||
assert!(!same_words(&body.text(), "other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_fragment_is_refused() {
|
||||
let html = "x".repeat(MAX_HTML + 1);
|
||||
assert!(html_to_body(&html).is_none());
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_slide,
|
||||
freeform
|
||||
),
|
||||
cmd!(
|
||||
"draw.inkToShape",
|
||||
"Ink to Shape",
|
||||
["Draw", "Convert"],
|
||||
None,
|
||||
"{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles",
|
||||
has_slide,
|
||||
ink_to_shape
|
||||
),
|
||||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||||
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
|
||||
cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate),
|
||||
cmd!(
|
||||
"shape.rotate",
|
||||
"Rotation",
|
||||
["Shape Format", "Arrange", "Rotate"],
|
||||
None,
|
||||
"{deg?: absolute, by?: relative, ids?, gesture?: move|end}",
|
||||
has_selection,
|
||||
rotate
|
||||
),
|
||||
cmd!(
|
||||
"shape.sizePosition",
|
||||
"Size and Position…",
|
||||
@@ -336,6 +353,15 @@ fn size_position(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A trackpad twist sends many small steps. `gesture: move` keeps them as one undo;
|
||||
// `gesture: end` commits that step and does not rotate again.
|
||||
if p.get("gesture").and_then(Value::as_str) == Some("end") {
|
||||
s.commit_named("Rotation");
|
||||
return Ok(json!({}));
|
||||
}
|
||||
if p.get("gesture").and_then(Value::as_str) == Some("move") {
|
||||
s.begin("Rotation");
|
||||
}
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
@@ -1246,3 +1272,127 @@ mod size_position_tests {
|
||||
assert!(err.to_string().contains("nothing selected"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape
|
||||
/// outlined in the stroke's colour and width; the rest stays ink. One undo step.
|
||||
fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
use crate::inkshape::{Recognized, recognize};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
let shapes = s.doc()?.shapes();
|
||||
let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {
|
||||
return Ok(json!({"shapes": [], "kept": 0}));
|
||||
};
|
||||
let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));
|
||||
let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };
|
||||
let found: Vec<Option<Recognized>> = strokes
|
||||
.iter()
|
||||
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
|
||||
.collect();
|
||||
if found.iter().all(Option::is_none) {
|
||||
return Ok(json!({"shapes": [], "kept": strokes.len()}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mut new = vec![];
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };
|
||||
let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };
|
||||
let all = std::mem::take(strokes);
|
||||
let mut kept = vec![];
|
||||
let mut made = vec![];
|
||||
for (st, r) in all.into_iter().zip(found.iter()) {
|
||||
let Some(r) = r else {
|
||||
kept.push(st);
|
||||
continue;
|
||||
};
|
||||
let (preset, xfrm) = match *r {
|
||||
Recognized::Line { x0, y0, x1, y1 } => {
|
||||
("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))
|
||||
}
|
||||
Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||||
Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),
|
||||
};
|
||||
made.push((preset, xfrm, st.color, st.width));
|
||||
}
|
||||
let remaining = kept.len();
|
||||
*strokes = kept;
|
||||
if remaining == 0 {
|
||||
list.remove(pos);
|
||||
}
|
||||
for (preset, xfrm, color, width) in made {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
let name = format!(
|
||||
"{} {}",
|
||||
match preset {
|
||||
"line" => "Straight Connector",
|
||||
"rect" => "Rectangle",
|
||||
"ellipse" => "Oval",
|
||||
_ => "Isosceles Triangle",
|
||||
},
|
||||
id.0
|
||||
);
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||||
list.push(Shape {
|
||||
id,
|
||||
name,
|
||||
xfrm: Some(xfrm),
|
||||
geom: Geom::preset(preset),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),
|
||||
kind: ShapeKind::Shape,
|
||||
..Default::default()
|
||||
});
|
||||
new.push(id);
|
||||
}
|
||||
sel.shapes = new.clone();
|
||||
Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
st.shape(id).unwrap().xfrm.unwrap().rot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_twist_is_one_undo_and_wraps_past_a_full_turn() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap();
|
||||
let id = id.get("id").and_then(Value::as_u64).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
assert!((rotation(&s) - 0.0).abs() < 0.01);
|
||||
|
||||
s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap();
|
||||
assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s));
|
||||
s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||||
assert!((rotation(&s) - 20.0).abs() < 0.01);
|
||||
assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation");
|
||||
assert!(s.active().unwrap().interaction.is_none());
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((rotation(&s) - 20.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ending_a_twist_does_not_commit_a_drag() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||||
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle.
|
||||
//!
|
||||
//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends
|
||||
//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1,
|
||||
//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the
|
||||
//! centre.
|
||||
|
||||
/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Recognized {
|
||||
/// From `(x0, y0)` to `(x1, y1)`.
|
||||
Line {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
x1: f64,
|
||||
y1: f64,
|
||||
},
|
||||
Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
Ellipse {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
/// Apex at the top (`flip_v` false) or at the bottom.
|
||||
Triangle {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
flip_v: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Strokes smaller than this (points) stay ink.
|
||||
const MIN_SIZE: f64 = 6.0;
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
pub fn recognize(points: &[(f64, f64)]) -> Option<Recognized> {
|
||||
let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect();
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for (x, y) in &pts {
|
||||
x0 = x0.min(*x);
|
||||
y0 = y0.min(*y);
|
||||
x1 = x1.max(*x);
|
||||
y1 = y1.max(*y);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let size = w.max(h);
|
||||
if !size.is_finite() || size < MIN_SIZE {
|
||||
return None;
|
||||
}
|
||||
let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum();
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
let gap = (last.0 - first.0).hypot(last.1 - first.1);
|
||||
if length <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Open stroke: a line if nearly straight, otherwise not a shape.
|
||||
if gap > 0.25 * size {
|
||||
return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 });
|
||||
}
|
||||
// Closed but flat: a line drawn back and forth isn't a shape.
|
||||
if w.min(h) < 0.15 * size {
|
||||
return None;
|
||||
}
|
||||
// Covered area (shoelace, the gap closed) over the bounding box.
|
||||
let mut a2 = 0.0;
|
||||
for k in 0..pts.len() {
|
||||
let (p, q) = (pts[k], pts[(k + 1) % pts.len()]);
|
||||
a2 += p.0 * q.1 - q.0 * p.1;
|
||||
}
|
||||
let cover = (a2.abs() / 2.0) / (w * h);
|
||||
// Ellipse fit: distance from the centre in box-normalised units should stay near 1.
|
||||
let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0);
|
||||
let radial: Vec<f64> = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect();
|
||||
let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::<f64>() / radial.len() as f64;
|
||||
if cover > 0.86 {
|
||||
Some(Recognized::Rect { x: x0, y: y0, w, h })
|
||||
} else if cover > 0.66 && spread < 0.12 {
|
||||
Some(Recognized::Ellipse { x: x0, y: y0, w, h })
|
||||
} else if (0.35..0.66).contains(&cover) {
|
||||
// Apex: the side of the box with fewer points near it is where the triangle narrows.
|
||||
let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count();
|
||||
let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count();
|
||||
Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> {
|
||||
let mut v = vec![];
|
||||
for k in 0..corners.len() {
|
||||
let (a, b) = (corners[k], corners[(k + 1) % corners.len()]);
|
||||
for i in 0..per_side {
|
||||
let t = i as f64 / per_side as f64;
|
||||
// A little hand wobble.
|
||||
let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8;
|
||||
v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob));
|
||||
}
|
||||
}
|
||||
v.push((corners[0].0 + 2.0, corners[0].1 + 1.5));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_ellipses_triangles_and_lines() {
|
||||
let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap();
|
||||
assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}");
|
||||
let ell: Vec<(f64, f64)> = (0..=80)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 80.0 * std::f64::consts::TAU;
|
||||
(200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell));
|
||||
let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap();
|
||||
assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}");
|
||||
let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap();
|
||||
assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}");
|
||||
let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect();
|
||||
assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribbles_dots_and_garbage_stay_ink() {
|
||||
assert_eq!(recognize(&[]), None);
|
||||
assert_eq!(recognize(&[(1.0, 1.0)]), None);
|
||||
assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small");
|
||||
let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect();
|
||||
assert_eq!(recognize(&zig), None, "an open zigzag is no line");
|
||||
assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None);
|
||||
let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect();
|
||||
assert_eq!(recognize(&there_and_back), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_to_shape_command_converts_and_undoes() {
|
||||
use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
let mut s = crate::Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30);
|
||||
let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect();
|
||||
let mk = |v: &[(f64, f64)]| InkStroke {
|
||||
points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(),
|
||||
color: deckcraft_color::Rgba::rgb(200, 0, 0),
|
||||
width: 3.0,
|
||||
highlighter: false,
|
||||
};
|
||||
let strokes = vec![mk(&rect), mk(&zig)];
|
||||
s.edit(|doc, sel| {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
crate::shapes_mut(doc, sel).unwrap().push(Shape {
|
||||
id,
|
||||
name: "Ink".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
kind: ShapeKind::Ink { strokes },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(serde_json::Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
let n0 = s.doc().unwrap().shapes().len();
|
||||
let r = s.execute("draw.inkToShape", &json!({})).unwrap();
|
||||
assert_eq!(r["kept"], 1);
|
||||
let sh = s.doc().unwrap().shapes();
|
||||
assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added");
|
||||
let made = sh.last().unwrap();
|
||||
assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"));
|
||||
assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), n0);
|
||||
// Nothing recognisable: a no-op, not an error. Empty slide too.
|
||||
s.execute("draw.inkToShape", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod drawtable;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
pub mod inkshape;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
pub mod recovery;
|
||||
@@ -353,6 +354,29 @@ impl Default for Prefs {
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// The preferences with every value in a range the editor can use: `prefs.json` is a file
|
||||
/// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo
|
||||
/// limit) must not break editing or exhaust memory. Out-of-range numbers take their
|
||||
/// defaults or the nearest usable value.
|
||||
pub fn sanitized(mut self) -> Prefs {
|
||||
let d = Prefs::default();
|
||||
let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default };
|
||||
self.grid_spacing =
|
||||
if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing };
|
||||
self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset);
|
||||
self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT);
|
||||
self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes);
|
||||
if self.author.chars().count() > 256 {
|
||||
self.author = self.author.chars().take(256).collect();
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The most undo steps [`Prefs::undo_limit`] can keep.
|
||||
pub const MAX_UNDO_LIMIT: usize = 10_000;
|
||||
|
||||
/// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
|
||||
@@ -84,6 +84,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
/// full of entries can't stall start-up.
|
||||
pub const MAX_REOPEN: usize = 32;
|
||||
/// Largest entry read back.
|
||||
const MAX_ENTRY_BYTES: u64 = 2 << 30;
|
||||
const MAX_META_BYTES: u64 = 64 << 10;
|
||||
|
||||
/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an
|
||||
/// entry belongs would block start-up forever.
|
||||
fn read_regular(p: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let m = std::fs::metadata(p).ok()?;
|
||||
(m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten()
|
||||
}
|
||||
|
||||
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
@@ -136,11 +150,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = std::fs::read_to_string(p.with_extension("json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
.filter(Value::is_object)
|
||||
.unwrap_or(json!({}));
|
||||
out.push((uid, meta));
|
||||
@@ -153,11 +169,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
/// Entries stay on disk, now owned by this instance: the presentation keeps the entry's uid, so
|
||||
/// the next recovery save replaces the snapshot and saving or closing it removes it. Entries of a
|
||||
/// running instance, unreadable entries and entries already open here are left alone. Returns the
|
||||
/// opened documents' indices.
|
||||
/// opened documents' indices. At most [`MAX_REOPEN`] are opened per start, the newest first; the rest
|
||||
/// wait for the next one.
|
||||
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
let me = instance_id();
|
||||
let mut opened = Vec::new();
|
||||
for (uid, mut meta) in list(dir) {
|
||||
let mut entries = list(dir);
|
||||
entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid)));
|
||||
for (uid, mut meta) in entries {
|
||||
if opened.len() >= MAX_REOPEN {
|
||||
break;
|
||||
}
|
||||
if s.documents().iter().any(|d| d.uid == uid) {
|
||||
continue;
|
||||
}
|
||||
@@ -168,7 +190,7 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
continue;
|
||||
}
|
||||
let (doc, meta_path) = files(dir, uid);
|
||||
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
||||
let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue };
|
||||
let Ok(mut d) = deckcraft_format::load(&bytes) else { continue };
|
||||
deckcraft_format::repair(&mut d);
|
||||
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
@@ -311,4 +333,36 @@ mod tests {
|
||||
assert_eq!(v.as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked
|
||||
/// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn hostile_recovery_folders_dont_stall_start_up() {
|
||||
let dir = temp("recovery-hostile");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
save(&s, &dir).unwrap();
|
||||
let (good, _) = files(&dir, s.doc().unwrap().uid);
|
||||
for i in 1..=(MAX_REOPEN as u64 + 3) {
|
||||
std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap();
|
||||
}
|
||||
for name in ["5.deckcraft", "6.json"] {
|
||||
let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status();
|
||||
}
|
||||
std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let d = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut s2 = Session::new();
|
||||
let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0));
|
||||
});
|
||||
let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung");
|
||||
assert_eq!(n, MAX_REOPEN);
|
||||
// Every entry stays on disk (main keeps snapshots until a newer save); the 32 opened plus
|
||||
// the rest that wait for the next start.
|
||||
assert_eq!(list(&dir).len(), MAX_REOPEN + 5, "the rest wait for the next start");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,62 @@ fn clipboard_copy_paste_shapes_and_slides() {
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_copy_is_html_and_pages_html_pastes_bold() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello"})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
let v = s.execute("edit.copy", &json!({})).unwrap();
|
||||
let html = v["html"].as_str().unwrap();
|
||||
assert!(html.contains("<b>"), "{html}");
|
||||
assert_eq!(v["text"], "Hello");
|
||||
// The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed.
|
||||
s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap();
|
||||
s.execute("edit.paste", &json!({"text": "Hello", "html": "<p>Hello</p>"})).unwrap();
|
||||
let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap();
|
||||
assert_eq!(body.text(), "HelloHello");
|
||||
assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}");
|
||||
s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap();
|
||||
let caret = s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(caret["text"], "");
|
||||
assert_eq!(caret["html"], "");
|
||||
|
||||
let mut ext = session();
|
||||
ext.execute("edit.paste", &json!({"text": "Hello", "html": "<p style=\"font-weight: bold\">Hello</p>"})).unwrap();
|
||||
let pasted = ext
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello"))
|
||||
.unwrap()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}");
|
||||
|
||||
let mut mismatched = session();
|
||||
mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "<b>Nope</b>"})).unwrap();
|
||||
let plain = mismatched
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi"))
|
||||
.unwrap()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_roundtrip_positions() {
|
||||
let mut s = session();
|
||||
@@ -287,6 +343,34 @@ fn base64_roundtrip() {
|
||||
assert!(cmd::base64_decode("!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_slide_body_tabs_match_text_set() {
|
||||
let body = "Install from DMG\nLaunch app\n\tCheck signing\n\t\tDeep\nBuild a deck";
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Test Plan", "body": body})).unwrap();
|
||||
let levels = |s: &Session| {
|
||||
let slide = s.doc().unwrap().current_slide().unwrap();
|
||||
let sh = slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap();
|
||||
let rows: Vec<(u8, String)> = sh.text.as_ref().unwrap().paragraphs.iter().map(|p| (p.level, p.text())).collect();
|
||||
(sh.id.0, rows)
|
||||
};
|
||||
let (id, from_new) = levels(&s);
|
||||
assert_eq!(
|
||||
from_new,
|
||||
vec![
|
||||
(0, "Install from DMG".to_string()),
|
||||
(0, "Launch app".to_string()),
|
||||
(1, "Check signing".to_string()),
|
||||
(2, "Deep".to_string()),
|
||||
(0, "Build a deck".to_string()),
|
||||
]
|
||||
);
|
||||
s.execute("text.set", &json!({"id": id, "text": body})).unwrap();
|
||||
assert_eq!(levels(&s).1, from_new);
|
||||
s.execute("text.set", &json!({"id": id, "text": "\t\t\t\t\t\t\t\t\tCapped"})).unwrap();
|
||||
assert_eq!(levels(&s).1, vec![(8, "Capped".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_change_keeps_text() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -369,7 +369,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin(&mut self, label: &str) {
|
||||
pub(crate) fn begin(&mut self, label: &str) {
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.is_none()
|
||||
{
|
||||
@@ -386,6 +386,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// End the interaction only when it is the one `label` started. A twist must not commit a drag.
|
||||
pub(crate) fn commit_named(&mut self, label: &str) {
|
||||
let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);
|
||||
if ours {
|
||||
self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// Next click of a freeform: close on the start point, else add a vertex.
|
||||
|
||||
@@ -459,6 +459,22 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folder depth below a font folder that the system scan descends.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCAN_DEPTH: usize = 12;
|
||||
/// Directory entries the system scan looks at in all.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCAN_ENTRIES: usize = 200_000;
|
||||
/// The largest font file read (the biggest CJK collections are a few hundred MB).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const MAX_FONT_FILE: u64 = 512 << 20;
|
||||
|
||||
/// A regular file of a size a font can have.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn font_file_ok(meta: &std::fs::Metadata) -> bool {
|
||||
meta.is_file() && meta.len() > 12 && meta.len() <= MAX_FONT_FILE
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
/// Empty on wasm.
|
||||
pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
@@ -917,24 +933,41 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut found = Vec::new();
|
||||
let mut stack = self.font_dirs.clone();
|
||||
let mut stack: Vec<(std::path::PathBuf, usize)> = self.font_dirs.iter().map(|d| (d.clone(), 0)).collect();
|
||||
// Each folder once, however links lead back to it.
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(d) = stack.pop() {
|
||||
let mut entries = 0usize;
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
// A link to `/` or a vast tree mustn't turn start-up into a disk crawl.
|
||||
entries += 1;
|
||||
if entries > MAX_SCAN_ENTRIES {
|
||||
log::warn!("font scan stopped after {MAX_SCAN_ENTRIES} entries");
|
||||
stack.clear();
|
||||
break;
|
||||
}
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
// Follows links: what matters is what the entry leads to.
|
||||
let Ok(meta) = std::fs::metadata(&p) else { continue };
|
||||
if meta.is_dir() {
|
||||
if depth < MAX_SCAN_DEPTH {
|
||||
stack.push((p, depth + 1));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
// Only regular files: opening a FIFO named like a font blocks until something
|
||||
// writes to it (start-up hung), and a device never ends.
|
||||
if !font_file_ok(&meta) {
|
||||
continue;
|
||||
}
|
||||
for (family, style) in file_face_names(&p) {
|
||||
found.push(CatalogEntry { family, style, path: p.clone() });
|
||||
}
|
||||
@@ -966,7 +999,7 @@ impl FontDb {
|
||||
p.dedup();
|
||||
p
|
||||
};
|
||||
self.add_fonts(paths.iter().filter_map(|p| std::fs::read(p).ok()).collect());
|
||||
self.add_fonts(paths.iter().filter(|p| std::fs::metadata(p).is_ok_and(|m| font_file_ok(&m))).filter_map(|p| std::fs::read(p).ok()).collect());
|
||||
self.is_loaded(family)
|
||||
}
|
||||
|
||||
|
||||
@@ -419,4 +419,30 @@ mod tests {
|
||||
truncated.truncate(40);
|
||||
assert!(embedded_sfnt(&truncated).is_none());
|
||||
}
|
||||
|
||||
/// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them
|
||||
/// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and
|
||||
/// still finds the real font beside them.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_font_scan_skips_fifos_folders_and_link_loops() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap();
|
||||
let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success());
|
||||
std::fs::create_dir(dir.join("folder.ttf")).unwrap();
|
||||
std::fs::write(dir.join("empty.otf"), b"").unwrap();
|
||||
std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let scan_dir = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let db = FontDb::with_font_dirs(vec![scan_dir]);
|
||||
let _ = tx.send(db.load_system_fonts());
|
||||
});
|
||||
let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung");
|
||||
assert!(n >= 1, "the real font was found");
|
||||
assert!(fifo || n >= 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +504,21 @@ impl Paragraph {
|
||||
pub fn new(text: &str) -> Self {
|
||||
Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() }
|
||||
}
|
||||
/// Leading tabs are the outline level (at most 8), the same rule as a typed indent.
|
||||
/// Returns whether any tab was consumed.
|
||||
pub fn promote_leading_tabs(&mut self) -> bool {
|
||||
let Some(r) = self.runs.first_mut() else { return false };
|
||||
if r.kind != RunKind::Text {
|
||||
return false;
|
||||
}
|
||||
let tabs = r.text.chars().take_while(|c| *c == '\t').count();
|
||||
if tabs == 0 {
|
||||
return false;
|
||||
}
|
||||
r.text = r.text.trim_start_matches('\t').to_string();
|
||||
self.level = tabs.min(8) as u8;
|
||||
true
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.content()).collect()
|
||||
}
|
||||
|
||||
@@ -2233,6 +2233,49 @@ fn draw_glyph_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture).
|
||||
pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option<Image> {
|
||||
if ids.is_empty() || !scale.is_finite() || scale <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let rctx = Ctx::for_slide(pres, slide)?;
|
||||
let mut items = Vec::new();
|
||||
let mut bounds: Option<Rect> = None;
|
||||
for id in ids.iter().take(256) {
|
||||
let Some(s) = slide.shape(*id) else { continue };
|
||||
let b = resolve::xfrm(&rctx, s).bounds();
|
||||
if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 {
|
||||
continue;
|
||||
}
|
||||
bounds = Some(match bounds {
|
||||
Some(u) => u.union(b),
|
||||
None => b,
|
||||
});
|
||||
items.push(s);
|
||||
}
|
||||
let b = bounds?.inflate(8.0, 8.0);
|
||||
if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let fit = (2048.0 / b.width().max(b.height())).min(scale);
|
||||
if !(fit.is_finite() && fit > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16;
|
||||
let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16;
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let opts = RenderOpts { scale: fit, ..Default::default() };
|
||||
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
|
||||
let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2());
|
||||
RENDERER.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
for s in items {
|
||||
r.shape(&mut ctx, &f, &rctx, s, view, 0);
|
||||
}
|
||||
Some(r.finish(ctx, w, h))
|
||||
})
|
||||
}
|
||||
|
||||
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
|
||||
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
|
||||
let rctx = Ctx::for_slide(pres, slide)?;
|
||||
|
||||
@@ -35,6 +35,22 @@ fn shape_fill_uses_theme_accent() {
|
||||
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shapes_draws_the_selection_on_a_transparent_canvas() {
|
||||
let sh = Shape {
|
||||
id: ShapeId(880),
|
||||
xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)),
|
||||
fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))),
|
||||
line: Some(deckcraft_model::Line::none()),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture");
|
||||
let mid = img.pixel(img.width / 2, img.height / 2);
|
||||
assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}");
|
||||
assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_draws_ink() {
|
||||
let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() };
|
||||
|
||||
@@ -35,6 +35,33 @@ fn mods(ui: &Ui) -> Mods {
|
||||
ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl })
|
||||
}
|
||||
|
||||
/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`];
|
||||
/// other platforms leave the delta at zero.
|
||||
///
|
||||
/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom
|
||||
/// and two-finger scrolling are left as they are.
|
||||
pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let delta = ctx.input(|i| i.rotation_delta());
|
||||
let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 };
|
||||
let turning = delta.abs() > 1e-4;
|
||||
let blocked = app.show.is_some()
|
||||
|| app.backstage_open()
|
||||
|| app.dialog.as_ref().is_some_and(|d| d.modal())
|
||||
|| app.palette.is_some()
|
||||
|| app.session.tool.dragging();
|
||||
let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)));
|
||||
let rotatable = app.session.active().is_some_and(|d| {
|
||||
d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some()))
|
||||
});
|
||||
if !blocked && turning && rotatable && (app.twisting || over) {
|
||||
let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"}));
|
||||
app.twisting = true;
|
||||
} else if app.twisting {
|
||||
let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []}));
|
||||
app.twisting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The slide's rect on screen for the current zoom.
|
||||
pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
|
||||
let Some(d) = app.session.active() else { return (avail, 1.0) };
|
||||
@@ -44,7 +71,8 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
|
||||
let (sw, sh) = (side(page.width), side(page.height));
|
||||
let margin = 28.0;
|
||||
let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05);
|
||||
let scale = app.ui.zoom.unwrap_or(fit);
|
||||
// A zoom from outside the editor's own controls (settings, the control protocol) is kept in range.
|
||||
let scale = app.ui.zoom.filter(|z| z.is_finite() && *z > 0.0).map_or(fit, |z| z.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end()));
|
||||
let size = vec2(sw * scale, sh * scale);
|
||||
let mut r = Rect::from_center_size(avail.center(), size);
|
||||
if size.x > avail.width() {
|
||||
@@ -56,6 +84,17 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
|
||||
(r, scale)
|
||||
}
|
||||
|
||||
/// A trackpad flick or mouse-wheel notch that should turn the page.
|
||||
///
|
||||
/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative
|
||||
/// movement — the content moves left or up, which is a swipe left or up with natural scrolling —
|
||||
/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides.
|
||||
pub(crate) fn page_flick(wheel: egui::Vec2) -> Option<bool> {
|
||||
const MIN_FLICK: f32 = 18.0;
|
||||
let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y };
|
||||
(along.abs() > MIN_FLICK).then_some(along < 0.0)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
@@ -95,15 +134,20 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if resp.hovered() && !zooming {
|
||||
if overflow != egui::Vec2::ZERO {
|
||||
offset -= wheel;
|
||||
} else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() {
|
||||
// Wheel over a fitted slide pages through slides (like PowerPoint).
|
||||
let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" };
|
||||
} else if !app.anim_preview_active()
|
||||
&& app.session.tool.marquee.is_none()
|
||||
&& !app.session.tool.dragging()
|
||||
&& let Some(next) = page_flick(wheel)
|
||||
{
|
||||
// A fitted slide: the mouse wheel and a trackpad swipe turn the page. Pinch zoom is
|
||||
// the branch above and is left alone.
|
||||
let gate = ui.id().with("wheel_gate");
|
||||
let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0));
|
||||
// No previous flick yet: the first one must turn the page, not wait out the gap.
|
||||
let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY));
|
||||
let now = ui.input(|i| i.time);
|
||||
if now - last > 0.35 {
|
||||
ui.data_mut(|d| d.insert_temp(gate, now));
|
||||
let _ = app.run(id, json!({}));
|
||||
let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +173,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
};
|
||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
let anim = app.anim_preview_draw();
|
||||
let st = app.session.active();
|
||||
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||
@@ -137,7 +182,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
let frame = anim.as_ref().map(|(tl, t)| (tl, *t));
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, frame)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
@@ -223,17 +269,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ruler(&painter, avail, slide_rect, scale, &t);
|
||||
}
|
||||
pointer(app, ui, &resp, xf);
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
ime_output(app, ui, xf);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64));
|
||||
app.squiggles.menu_word = word;
|
||||
if !app.anim_preview_active() {
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
ime_output(app, ui, xf);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64));
|
||||
app.squiggles.menu_word = word;
|
||||
}
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
|
||||
@@ -380,14 +428,18 @@ fn master_tex(
|
||||
visible: deckcraft_render::Region,
|
||||
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
|
||||
let region = crate::textures::fit_texture(
|
||||
deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible),
|
||||
visible,
|
||||
crate::textures::max_side(ctx),
|
||||
);
|
||||
let img = deckcraft_render::render_layout(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
||||
Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
@@ -437,6 +489,14 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
if crate::eyedropper::canvas(app, ui, resp, xf) {
|
||||
return;
|
||||
}
|
||||
if app.anim_preview_active() {
|
||||
// A click on the slide ends the preview, then selects as usual.
|
||||
if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
app.stop_anim_preview();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let tol = (4.0 / xf.scale) as f64;
|
||||
let m = mods(ui);
|
||||
let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return };
|
||||
|
||||
@@ -83,6 +83,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
|
||||
let now = ctx.input(|i| i.time);
|
||||
s.showing_value(n, aspect, ctx.content_rect(), now)
|
||||
}),
|
||||
"animPreview": app.anim_preview_report().map(|(slide, time, duration)| json!({"slide": slide, "time": time, "duration": duration})),
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
|
||||
"status": app.status.as_ref().map(|s| s.0.clone()),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
|
||||
@@ -141,6 +142,10 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(k) = s("key") else { return err("missing `key`") };
|
||||
if app.anim_preview_active() && app.show.is_none() && matches!(k.to_ascii_lowercase().as_str(), "escape" | "esc") {
|
||||
app.stop_anim_preview();
|
||||
return ok(json!({"preview": false}));
|
||||
}
|
||||
if let Some(show) = app.show.as_ref()
|
||||
&& !show.ended
|
||||
&& let Some(key) = key_from(k)
|
||||
@@ -236,7 +241,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
};
|
||||
}
|
||||
if let Some(z) = p.get("zoom") {
|
||||
app.ui.zoom = z.as_f64().map(|v| v as f32);
|
||||
app.ui.zoom = z
|
||||
.as_f64()
|
||||
.map(|v| v as f32)
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(|v| v.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end()));
|
||||
}
|
||||
if let Some(d) = p.get("dark").and_then(Value::as_bool) {
|
||||
let _ = app.run("view.dark", json!({"on": d}));
|
||||
|
||||
@@ -164,7 +164,7 @@ fn ensure_texture(
|
||||
slide,
|
||||
&RenderOpts { scale: w as f64 / doc.slide_size.width.max(1.0), size: Some((w, h)), edit: true, threads: 0, ..Default::default() },
|
||||
);
|
||||
let tex = ctx.load_texture("doc-window-slide", crate::textures::to_color_image(&img, grayscale), egui::TextureOptions::LINEAR);
|
||||
let tex = crate::textures::upload(ctx, "doc-window-slide", crate::textures::to_color_image(&img, grayscale));
|
||||
if let Some(win) = app.doc_window.as_mut() {
|
||||
win.key = Some(key);
|
||||
win.tex = Some(tex.clone());
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! #414: a browser file pick finishes on the document, slide and operation that started it.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use deckcraft_model::ShapeKind;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
|
||||
use crate::Session;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
// 1×1 RGB PNG. Enough for `insert.picture` to accept it.
|
||||
const HEX: &str =
|
||||
"89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c49444154789c63f8cfc0000003010100c9fe92ef0000000049454e44ae426082";
|
||||
(0..HEX.len()).step_by(2).map(|i| u8::from_str_radix(&HEX[i..i + 2], 16).expect("hex")).collect()
|
||||
}
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let data = [0u8; 8];
|
||||
let mut b = b"RIFF".to_vec();
|
||||
b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(b"WAVEfmt ");
|
||||
b.extend_from_slice(&16u32.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&8000u32.to_le_bytes());
|
||||
b.extend_from_slice(&16000u32.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&16u16.to_le_bytes());
|
||||
b.extend_from_slice(b"data");
|
||||
b.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&data);
|
||||
b
|
||||
}
|
||||
|
||||
fn setup() -> (SlideApp, Inbox, Arc<Mutex<Vec<(String, u64)>>>) {
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let started = Arc::new(Mutex::new(Vec::new()));
|
||||
let record = started.clone();
|
||||
let services = Services {
|
||||
inbox: Some(inbox.clone()),
|
||||
open_async: Some(Box::new(move |purpose, id| {
|
||||
record.lock().expect("request log").push((purpose.to_string(), id));
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(SlideApp::new(Session::with_new(), services), inbox, started)
|
||||
}
|
||||
|
||||
fn deliver(inbox: &Inbox, id: u64, name: &str, bytes: Vec<u8>) {
|
||||
inbox.lock().expect("inbox").push(InboxEvent::File(InboxFile { id, name: name.into(), bytes }));
|
||||
}
|
||||
|
||||
fn pictures(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize {
|
||||
let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document");
|
||||
let i = st.doc.slide_index(slide).expect("slide");
|
||||
st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
|
||||
fn audios(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize {
|
||||
let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document");
|
||||
let i = st.doc.slide_index(slide).expect("slide");
|
||||
st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Media(ref m) if !m.video)).count()
|
||||
}
|
||||
|
||||
/// Two picks finish in reverse order, after the user has changed slide and document.
|
||||
/// Each file uses the operation that was requested and lands on the slide that asked.
|
||||
#[test]
|
||||
fn late_picker_files_keep_their_document_slide_and_operation() {
|
||||
let (mut app, inbox, started) = setup();
|
||||
let uid_a = app.session.active().unwrap().uid;
|
||||
let slide0 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
let slide1 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
assert_ne!(slide0, slide1);
|
||||
|
||||
// Audio was asked for on slide 1; the picture and the outline on slide 0.
|
||||
app.pick_and_insert("audio", "insert.audio");
|
||||
app.session.execute("slide.go", &json!({"id": slide0.0})).unwrap();
|
||||
app.pick_and_insert("picture", "insert.picture");
|
||||
app.pick_and_open();
|
||||
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let uid_b = app.session.active().unwrap().uid;
|
||||
assert_ne!(uid_a, uid_b);
|
||||
let shapes_b = app.session.active().unwrap().doc.slides[0].shapes.len();
|
||||
// A video was requested on B. Delivering a PNG must not insert a picture anywhere.
|
||||
app.pick_and_insert("video", "insert.video");
|
||||
|
||||
let log = started.lock().expect("request log").clone();
|
||||
assert_eq!(log.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), vec!["audio", "picture", "open", "video"]);
|
||||
let audio_id = log[0].1;
|
||||
let picture_id = log[1].1;
|
||||
let outline_id = log[2].1;
|
||||
let video_id = log[3].1;
|
||||
|
||||
// The video request completes first, while B is still current. A PNG must not become a picture.
|
||||
deliver(&inbox, video_id, "clip.mp4", png());
|
||||
app.drain_inbox();
|
||||
let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default();
|
||||
assert!(status.contains("not a supported video") || status.contains("no video"), "{status}");
|
||||
assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b);
|
||||
|
||||
// The earlier picks then finish in reverse order.
|
||||
deliver(&inbox, outline_id, "talk.md", b"Brought Later\n".to_vec());
|
||||
deliver(&inbox, picture_id, "shot.png", png());
|
||||
deliver(&inbox, audio_id, "tone.wav", wav());
|
||||
app.drain_inbox();
|
||||
|
||||
assert_eq!(app.session.active().unwrap().uid, uid_b, "the view stays on the document the user switched to");
|
||||
assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b, "the video request must not insert the PNG here");
|
||||
assert_eq!(pictures(&app, uid_b, app.session.documents().iter().find(|d| d.uid == uid_b).unwrap().doc.slides[0].id), 0);
|
||||
|
||||
let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default();
|
||||
assert_eq!(pictures(&app, uid_a, slide0), 1, "{status}");
|
||||
assert_eq!(pictures(&app, uid_a, slide1), 0);
|
||||
assert_eq!(audios(&app, uid_a, slide1), 1);
|
||||
assert_eq!(audios(&app, uid_a, slide0), 0);
|
||||
let a = app.session.documents().iter().find(|d| d.uid == uid_a).unwrap();
|
||||
assert!(a.doc.slides.iter().any(|s| s.title() == "Brought Later"), "the outline stays on the document that opened it");
|
||||
assert_eq!(a.doc.slides.len(), 3);
|
||||
// The user had moved to slide 0 of A before switching documents; B is what's on screen.
|
||||
assert_eq!(app.session.active().unwrap().selection.slide, 0);
|
||||
}
|
||||
|
||||
/// Closing the presentation, or dismissing the picker, drops the file instead of inserting
|
||||
/// it into whatever is open now.
|
||||
#[test]
|
||||
fn a_closed_or_cancelled_request_does_not_insert_into_the_active_document() {
|
||||
let (mut app, inbox, started) = setup();
|
||||
let slide = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
app.pick_and_insert("picture", "insert.picture");
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
let kept = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
app.session.execute("slide.delete", &json!({"ids": [slide.0]})).unwrap();
|
||||
app.pick_and_insert("picture", "insert.picture");
|
||||
let log = started.lock().expect("request log").clone();
|
||||
let deleted_slide = log[0].1;
|
||||
let dismiss = log[1].1;
|
||||
|
||||
inbox.lock().expect("inbox").push(InboxEvent::Cancelled { id: dismiss });
|
||||
deliver(&inbox, dismiss, "late.png", png());
|
||||
deliver(&inbox, deleted_slide, "gone.png", png());
|
||||
// An id that was never started must not insert either.
|
||||
deliver(&inbox, 999, "stray.png", png());
|
||||
app.drain_inbox();
|
||||
|
||||
assert_eq!(pictures(&app, app.session.active().unwrap().uid, kept), 0);
|
||||
let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or("");
|
||||
assert!(status.contains("deleted"), "{status}");
|
||||
|
||||
app.session.execute("file.close", &json!({})).unwrap();
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
// The deleted-slide request was already consumed. Start another and close before it arrives.
|
||||
let closed = app.session.active().unwrap().uid;
|
||||
app.pick_and_insert("audio", "insert.audio");
|
||||
let audio_id = started.lock().expect("request log").last().unwrap().1;
|
||||
app.session.execute("file.close", &json!({})).unwrap();
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let other = app.session.active().unwrap().uid;
|
||||
assert_ne!(closed, other);
|
||||
deliver(&inbox, audio_id, "tone.wav", wav());
|
||||
app.drain_inbox();
|
||||
let st = app.session.documents().iter().find(|d| d.uid == other).unwrap();
|
||||
let n = st.doc.slides.iter().flat_map(|s| s.shapes.iter()).filter(|s| matches!(s.kind, ShapeKind::Media(_))).count();
|
||||
assert_eq!(n, 0, "a file for a closed presentation is not inserted into the one now open");
|
||||
let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or("");
|
||||
assert!(status.contains("closed"), "{status}");
|
||||
}
|
||||
|
||||
/// A drop is bound to the slide under the pointer at drop time, not the slide current when
|
||||
/// the bytes have been read.
|
||||
#[test]
|
||||
fn a_dropped_file_stays_on_the_slide_it_was_dropped_on() {
|
||||
let (mut app, inbox, _) = setup();
|
||||
let uid = app.session.active().unwrap().uid;
|
||||
let slide0 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
let id = app.begin_external_file("drop");
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
let slide1 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
deliver(&inbox, id, "shot.png", png());
|
||||
app.drain_inbox();
|
||||
assert_eq!(app.session.active().unwrap().current_slide().unwrap().id, slide1, "the view stays on the slide the user moved to");
|
||||
let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default();
|
||||
assert_eq!(pictures(&app, uid, slide0), 1, "{status}");
|
||||
assert_eq!(pictures(&app, uid, slide1), 0);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the
|
||||
//! mouse, press the button, get a rectangle shape.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn pen_a_rectangle_then_ink_to_shape() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.tab = "Draw".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Black Pen").click();
|
||||
h.run_steps(2);
|
||||
let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
|
||||
let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k);
|
||||
let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)];
|
||||
h.hover_at(at(200.0, 150.0));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
for w in corners.windows(2) {
|
||||
for i in 1..=12 {
|
||||
let t = i as f32 / 12.0;
|
||||
let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t);
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let has_ink = |h: &Harness<'_, SlideApp>| {
|
||||
h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))
|
||||
};
|
||||
assert!(has_ink(&h), "the pen drew a stroke");
|
||||
h.get_by_label("Ink to Shape").click();
|
||||
h.run_steps(3);
|
||||
assert!(!has_ink(&h), "the only stroke became a shape");
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh = st.shapes().last().unwrap();
|
||||
assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom);
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}");
|
||||
assert_eq!(st.selection.shapes, vec![sh.id]);
|
||||
if let Ok(img) = h.render() {
|
||||
let _ = img.save("/tmp/ink_to_shape.png");
|
||||
}
|
||||
}
|
||||
+854
-63
File diff suppressed because it is too large
Load Diff
@@ -287,8 +287,8 @@ impl MediaHost {
|
||||
{
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba);
|
||||
match v.tex.as_mut() {
|
||||
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
|
||||
None => v.tex = Some(ctx.load_texture(format!("media-{id}"), img, egui::TextureOptions::LINEAR)),
|
||||
Some(t) => crate::textures::replace(ctx, t, img),
|
||||
None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)),
|
||||
}
|
||||
v.frame = Some(f);
|
||||
}
|
||||
|
||||
+131
-13
@@ -151,6 +151,29 @@ fn run(app: &mut SlideApp, id: &str, p: Value) {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
|
||||
/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s
|
||||
/// `transparency` parameter writes as an alpha transform. Anything else — no fill, a
|
||||
/// gradient, a picture, a pattern — is 0, because that command only changes a solid.
|
||||
fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 {
|
||||
let Some(Fill::Solid { color }) = fill else {
|
||||
return 0.0;
|
||||
};
|
||||
((1.0 - color.alpha()) * 100.0) as f32
|
||||
}
|
||||
|
||||
/// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus
|
||||
/// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill.
|
||||
fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option<f64> {
|
||||
if !shown_percent.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let shown = shown_percent.clamp(0.0, 100.0);
|
||||
if (shown - seed_percent).abs() <= 0.01 {
|
||||
return None;
|
||||
}
|
||||
Some(f64::from(shown) / 100.0)
|
||||
}
|
||||
|
||||
fn scheme(app: &SlideApp) -> deckcraft_color::ColorScheme {
|
||||
app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| deckcraft_model::Theme::default().colors)
|
||||
}
|
||||
@@ -446,12 +469,19 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Transparency");
|
||||
let id = ui.id().with("ftr");
|
||||
let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0));
|
||||
// Seed from this shape. A shared temp defaulted to 0, so a shape that was
|
||||
// already 40% transparent showed as opaque, and leaving the slider wrote 0.
|
||||
let seed = solid_fill_transparency_percent(sh.fill.as_ref());
|
||||
let id = ui.id().with(("ftr", sh.id.0));
|
||||
let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed));
|
||||
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%"));
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
if r.drag_stopped() || r.lost_focus() {
|
||||
let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0}));
|
||||
if let Some(t) = fill_transparency_to_apply(v, seed) {
|
||||
let _ = app.run("shape.fill", json!({"transparency": t}));
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<f32>(id));
|
||||
} else if r.dragged() || r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
});
|
||||
if let Some(Fill::Gradient(g)) = &sh.fill {
|
||||
@@ -722,13 +752,9 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect();
|
||||
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("▶ Play All").clicked() {
|
||||
let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
|
||||
app.start_show(from, true);
|
||||
if let Some(s) = app.show.as_mut() {
|
||||
s.preview_only = true;
|
||||
s.autoplay = true;
|
||||
}
|
||||
let playing = app.anim_preview_active();
|
||||
if ui.button(if playing { "■ Stop" } else { "▶ Play All" }).clicked() {
|
||||
run(app, "animation.preview", json!({"on": !playing}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
@@ -1002,13 +1028,16 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if cache.key != key || cache.tex.is_empty() {
|
||||
cache = IdeaThumbs { key, tex: vec![] };
|
||||
let mut render = |name: String, d: &deckcraft_model::Presentation| {
|
||||
let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32;
|
||||
let (px, h) = crate::textures::fit_size(
|
||||
(px, (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
|
||||
crate::textures::max_side(ui.ctx()),
|
||||
);
|
||||
let img = deckcraft_render::render_slide(
|
||||
d,
|
||||
idx,
|
||||
&deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() },
|
||||
);
|
||||
let tex = ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false));
|
||||
cache.tex.push((name, tex, px, h));
|
||||
};
|
||||
for (id, _) in &ideas {
|
||||
@@ -1088,3 +1117,92 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec
|
||||
#[cfg(test)]
|
||||
#[path = "smartart_tests.rs"]
|
||||
mod smartart_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use deckcraft_color::{ColorTransform, SchemeSlot};
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::{ColorRef, Fill, ShapeId};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{fill_transparency_to_apply, solid_fill_transparency_percent};
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
#[test]
|
||||
fn solid_fill_transparency_reads_the_alpha_shape_fill_writes() {
|
||||
assert_eq!(solid_fill_transparency_percent(None), 0.0);
|
||||
assert_eq!(solid_fill_transparency_percent(Some(&Fill::None)), 0.0);
|
||||
assert_eq!(solid_fill_transparency_percent(Some(&Fill::Group)), 0.0);
|
||||
let plain = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1));
|
||||
assert_eq!(solid_fill_transparency_percent(Some(&plain)), 0.0);
|
||||
// `shape.fill` transparency 0.4 stores Alpha((1 - 0.4) * 100_000).
|
||||
let forty = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(60_000)));
|
||||
let p = solid_fill_transparency_percent(Some(&forty));
|
||||
assert!((p - 40.0).abs() < 0.01, "{p}");
|
||||
let clear = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0)));
|
||||
assert!((solid_fill_transparency_percent(Some(&clear)) - 100.0).abs() < 0.01);
|
||||
// A later alpha wins, matching ColorRef::alpha.
|
||||
let layered = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0)).with(ColorTransform::Alpha(60_000)));
|
||||
assert!((solid_fill_transparency_percent(Some(&layered)) - 40.0).abs() < 0.01);
|
||||
|
||||
assert_eq!(fill_transparency_to_apply(40.0, 40.0), None);
|
||||
assert_eq!(fill_transparency_to_apply(40.005, 40.0), None);
|
||||
assert_eq!(fill_transparency_to_apply(f32::NAN, 40.0), None);
|
||||
assert_eq!(fill_transparency_to_apply(55.0, 40.0), Some(0.55));
|
||||
assert_eq!(fill_transparency_to_apply(0.0, 40.0), Some(0.0));
|
||||
}
|
||||
|
||||
fn solid_alpha(app: &SlideApp, id: u32) -> Option<i32> {
|
||||
let sh = app.session.active()?.shape(ShapeId(id))?;
|
||||
let Fill::Solid { color } = sh.fill.as_ref()? else { return None };
|
||||
color.mods.iter().find_map(|m| match m {
|
||||
ColorTransform::Alpha(v) => Some(*v),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn slider_values(h: &Harness<'_, SlideApp>) -> Vec<f64> {
|
||||
h.query_all_by_role(egui::accesskit::Role::Slider).filter_map(|n| n.accesskit_node().numeric_value()).collect()
|
||||
}
|
||||
|
||||
/// The Fill slider shows the selected solid's transparency, and opening the pane
|
||||
/// (or looking at another shape first) does not write 0% over it.
|
||||
#[test]
|
||||
fn fill_transparency_slider_follows_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.fill", &json!({"id": a, "color": "accent1", "transparency": 0.0})).unwrap();
|
||||
s.execute("shape.fill", &json!({"id": b, "color": "accent1", "transparency": 0.4})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.pane = Some("format".into());
|
||||
app.ui.format_tab = "fill".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let opaque: Vec<f64> = slider_values(&h);
|
||||
assert!(opaque.iter().any(|v| (*v - 0.0).abs() < 0.5), "opaque shape should show 0%, got {opaque:?}");
|
||||
assert!(opaque.iter().all(|v| (*v - 40.0).abs() > 0.5), "opaque shape should not show 40%, got {opaque:?}");
|
||||
|
||||
h.state_mut().session.execute("edit.select", &json!({"ids": [b]})).unwrap();
|
||||
h.run_steps(3);
|
||||
let clear: Vec<f64> = slider_values(&h);
|
||||
assert!(clear.iter().any(|v| (*v - 40.0).abs() < 0.5), "40% fill should show 40%, got {clear:?}");
|
||||
assert_eq!(solid_alpha(h.state(), b), Some(60_000));
|
||||
|
||||
h.get_by_label("Transparency").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(solid_alpha(h.state(), b), Some(60_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! In-canvas animation preview: the current slide's effects played on the editor canvas
|
||||
//! (Animations ▸ Preview, Animation Pane ▸ Play All). The slide show uses the same mapping.
|
||||
|
||||
use deckcraft_anim::Timeline;
|
||||
use deckcraft_geom::Vec2;
|
||||
use deckcraft_model::ShapeId;
|
||||
use deckcraft_render::{ParaState, ShapeState};
|
||||
|
||||
/// How long the finished sequence stays on the canvas before the editor draws the slide at rest.
|
||||
pub const HOLD_SECS: f64 = 0.45;
|
||||
|
||||
/// Shape and paragraph state when `step` main steps are done and the current step has played `t` seconds.
|
||||
pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState {
|
||||
let a = tl.state(id, None, step, t);
|
||||
ShapeState {
|
||||
visible: a.visible,
|
||||
opacity: a.opacity,
|
||||
offset: Vec2::new(a.offset_x, a.offset_y),
|
||||
scale: (a.scale_x, a.scale_y),
|
||||
rotate: a.rotate,
|
||||
clip: a.clip,
|
||||
tint: a.tint,
|
||||
paras: tl
|
||||
.paragraph_targets(id)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let a = tl.state(id, Some(p), step, t);
|
||||
(p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) })
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// State on the play-everything clock (preview and Play All).
|
||||
pub fn shape_state_at(tl: &Timeline, id: ShapeId, time: f64) -> ShapeState {
|
||||
let (step, t) = tl.locate(time);
|
||||
shape_state(tl, id, step, t)
|
||||
}
|
||||
@@ -395,8 +395,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let tex =
|
||||
ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false));
|
||||
ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -1420,8 +1419,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false);
|
||||
let _ = r.on_hover_text("Ink to Shape (coming soon)");
|
||||
let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })));
|
||||
let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink);
|
||||
if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() {
|
||||
app.session.set_tool(ToolKind::Select);
|
||||
run(app, "draw.inkToShape", json!({}));
|
||||
}
|
||||
let r = big_button(ui, Icon::InkToText, "Ink to\nText", false);
|
||||
let _ = r.on_hover_text("Ink to Text (coming soon)");
|
||||
let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false);
|
||||
@@ -1646,12 +1649,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let opts: &[&str] = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]);
|
||||
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty());
|
||||
let selected = cur.as_ref().map(|t| t.option.as_str());
|
||||
let rows = transition_effect_rows(&kind, selected);
|
||||
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty());
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for o in opts {
|
||||
if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() {
|
||||
for &(o, on) in &rows {
|
||||
if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() {
|
||||
run(app, "transition.options", json!({"option": o}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1832,13 +1837,10 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
||||
s.animations.iter().find(|a| a.shape == *id).cloned()
|
||||
});
|
||||
group(ui, |ui| {
|
||||
if big_button(ui, Icon::Preview, "Preview", true).clicked() {
|
||||
let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
|
||||
app.start_show(from, true);
|
||||
if let Some(s) = app.show.as_mut() {
|
||||
s.preview_only = true;
|
||||
s.autoplay = true;
|
||||
}
|
||||
let playing = app.anim_preview_active();
|
||||
let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing);
|
||||
if resp.on_hover_text("Play this slide's animations on the canvas").clicked() {
|
||||
run(app, "animation.preview", json!({}));
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
@@ -1900,18 +1902,23 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
});
|
||||
let opts: &[&str] = cur.as_ref().and_then(|c| deckcraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]);
|
||||
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty());
|
||||
let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default();
|
||||
let build = cur.as_ref().map(|c| text_build_id(c.text_build));
|
||||
let build_rows = marked_options(&["asOne", "byParagraph"], build);
|
||||
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty());
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for o in opts {
|
||||
if ui.button(option_label(o)).clicked() {
|
||||
for &(o, on) in &effect_rows {
|
||||
if ui.selectable_label(on, option_label(o)).clicked() {
|
||||
run(app, "animation.options", json!({"option": o}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "animation.options", json!({"textBuild": b}));
|
||||
for &(id, on) in &build_rows {
|
||||
let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" };
|
||||
if ui.selectable_label(on, label).clicked() {
|
||||
run(app, "animation.options", json!({"textBuild": id}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3289,6 +3296,32 @@ fn handout_master(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The stored id is the one marked row. Every other id stays unmarked.
|
||||
fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> {
|
||||
options.iter().copied().map(|o| (o, current == Some(o))).collect()
|
||||
}
|
||||
|
||||
/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one.
|
||||
fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> {
|
||||
let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]);
|
||||
marked_options(opts, current)
|
||||
}
|
||||
|
||||
/// One row of Animations ▸ Effect Options for the selected effect.
|
||||
fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> {
|
||||
let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]);
|
||||
marked_options(opts, current)
|
||||
}
|
||||
|
||||
fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str {
|
||||
match build {
|
||||
deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph",
|
||||
deckcraft_model::anim::TextBuild::ByWord => "byWord",
|
||||
deckcraft_model::anim::TextBuild::ByLetter => "byLetter",
|
||||
deckcraft_model::anim::TextBuild::AsOne => "asOne",
|
||||
}
|
||||
}
|
||||
|
||||
/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble).
|
||||
fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) {
|
||||
let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
@@ -3315,6 +3348,7 @@ fn chart_menu_types() -> Vec<Option<deckcraft_model::chart::ChartType>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
|
||||
/// Paints one transition tile and one animation tile and returns the colour of every label.
|
||||
fn tile_label_colours(dark: bool) -> Vec<Color32> {
|
||||
@@ -3344,6 +3378,26 @@ mod tests {
|
||||
let light = tile_label_colours(false);
|
||||
assert_eq!(light, vec![Tokens::light().text; 2]);
|
||||
}
|
||||
|
||||
fn marked(rows: Vec<(&str, bool)>) -> Vec<&str> {
|
||||
rows.into_iter().filter(|(_, on)| *on).map(|(id, _)| id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_options_mark_only_the_stored_choice() {
|
||||
assert_eq!(marked(transition_effect_rows("push", Some("l"))), vec!["l"]);
|
||||
assert_eq!(transition_effect_rows("push", Some("l")).into_iter().map(|(_, on)| on).collect::<Vec<_>>(), vec![false, false, true, false]);
|
||||
assert_eq!(marked(transition_effect_rows("fade", Some("throughBlack"))), vec!["throughBlack"]);
|
||||
assert!(marked(transition_effect_rows("push", Some("missing"))).is_empty());
|
||||
assert!(transition_effect_rows("none", None).is_empty());
|
||||
assert_eq!(marked(transition_effect_rows("cut", Some(""))), vec![""]);
|
||||
assert_eq!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("l"))), vec!["l"]);
|
||||
assert!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("nope"))).is_empty());
|
||||
assert!(animation_effect_rows("appear", AnimClass::Entrance, Some("l")).is_empty());
|
||||
assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByParagraph)))), vec!["byParagraph"]);
|
||||
assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::AsOne)))), vec!["asOne"]);
|
||||
assert!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByWord)))).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3376,3 +3430,7 @@ mod title_bar_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "drawtable_tests.rs"]
|
||||
mod drawtable_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inktoshape_tests.rs"]
|
||||
mod inktoshape_tests;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! The desktop app's settings files, `ui.json` ([`UiState`]) and `prefs.json`
|
||||
//! ([`deckcraft_engine::Prefs`]), in the per-user settings folder. They are files on disk that a
|
||||
//! crash, a full disk or a hand edit can leave damaged, so reading never fails and never trusts
|
||||
//! them: an unreadable, oversized or malformed file gives the defaults, and the values of a
|
||||
//! well-formed one are brought into range ([`UiState::sanitized`], `Prefs::sanitized`). Writing
|
||||
//! goes through a temporary file and a rename, so a crash mid-write leaves the old file.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use deckcraft_engine::Prefs;
|
||||
|
||||
use crate::UiState;
|
||||
|
||||
pub const UI_FILE: &str = "ui.json";
|
||||
pub const PREFS_FILE: &str = "prefs.json";
|
||||
/// Larger settings files are not settings.
|
||||
pub const MAX_BYTES: u64 = 4 << 20;
|
||||
|
||||
/// The JSON in `path`, when it is a regular file of a sane size that holds a `T`.
|
||||
fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_BYTES {
|
||||
log::warn!("{}: not a settings file; using the defaults", path.display());
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}; using the defaults", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved UI state and preferences in `dir` (each `None` when missing or unusable).
|
||||
pub fn load(dir: &Path) -> (Option<UiState>, Option<Prefs>) {
|
||||
(read::<UiState>(&dir.join(UI_FILE)).map(UiState::sanitized), read::<Prefs>(&dir.join(PREFS_FILE)).map(Prefs::sanitized))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a temporary file beside it.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
}
|
||||
|
||||
/// Save both files into `dir` (created when missing). Failures are logged, not fatal.
|
||||
pub fn save(dir: &Path, ui: &UiState, prefs: &Prefs) {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
log::warn!("{}: {e}; settings not saved", dir.display());
|
||||
return;
|
||||
}
|
||||
for (name, json) in [(UI_FILE, serde_json::to_vec_pretty(ui)), (PREFS_FILE, serde_json::to_vec_pretty(prefs))] {
|
||||
let path = dir.join(name);
|
||||
if let Err(e) = json.map_err(std::io::Error::other).and_then(|b| write_atomic(&path, &b)) {
|
||||
log::warn!("{}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("deckcraft-settings-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_settings_files_give_the_defaults() {
|
||||
let d = dir("damaged");
|
||||
let cases: [&[u8]; 6] = [b"", b"{", b"\xFF\xFE garbage", b"[1, 2, 3]", b"{\"zoom\": \"big\"}", b"null"];
|
||||
for bytes in cases {
|
||||
std::fs::write(d.join(UI_FILE), bytes).unwrap();
|
||||
std::fs::write(d.join(PREFS_FILE), bytes).unwrap();
|
||||
let (ui, prefs) = load(&d);
|
||||
// Prefs has no `zoom`, so that case is a valid (default) Prefs.
|
||||
assert!(ui.is_none(), "{:?}", String::from_utf8_lossy(bytes));
|
||||
assert!(prefs.is_none() || bytes.starts_with(b"{\"zoom"), "{:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
// A folder where a file should be, and an oversized file.
|
||||
std::fs::remove_file(d.join(UI_FILE)).unwrap();
|
||||
std::fs::create_dir(d.join(UI_FILE)).unwrap();
|
||||
std::fs::write(d.join(PREFS_FILE), vec![b' '; MAX_BYTES as usize + 1]).unwrap();
|
||||
assert!(matches!(load(&d), (None, None)));
|
||||
// Nothing there at all.
|
||||
assert!(matches!(load(&d.join("missing")), (None, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_values_are_brought_into_range() {
|
||||
let d = dir("ranges");
|
||||
let recent: Vec<String> = (0..5000).map(|i| format!("/tmp/deck{i}.pptx")).collect();
|
||||
let ui = serde_json::json!({
|
||||
"zoom": 1.0e30, "sorterZoom": -4.0, "uiScale": 1.0e9, "notesHeight": 1.0e30,
|
||||
"thumbsWidth": -1.0e30, "paneWidth": 3.4e38, "tab": "x".repeat(10_000), "pane": "y".repeat(500),
|
||||
"recent": recent,
|
||||
});
|
||||
let prefs = serde_json::json!({"gridSpacing": 0.0, "duplicateOffset": 1.0e300, "undoLimit": u64::MAX, "recoveryMinutes": -5.0, "author": "a".repeat(100_000)});
|
||||
std::fs::write(d.join(UI_FILE), ui.to_string()).unwrap();
|
||||
std::fs::write(d.join(PREFS_FILE), prefs.to_string()).unwrap();
|
||||
let (ui, prefs) = load(&d);
|
||||
let (ui, prefs) = (ui.unwrap(), prefs.unwrap());
|
||||
assert_eq!(ui.zoom, Some(*crate::ZOOM_RANGE.end()));
|
||||
assert_eq!((ui.sorter_zoom, ui.ui_scale), (0.5, 3.0));
|
||||
assert!(ui.notes_height <= 2000.0 && ui.thumbs_width >= 60.0 && ui.pane_width <= 520.0);
|
||||
assert_eq!((ui.tab.as_str(), ui.pane.as_deref()), ("Home", None));
|
||||
assert_eq!(ui.recent.len(), crate::RECENT_MAX);
|
||||
assert_eq!(prefs.grid_spacing, Prefs::default().grid_spacing);
|
||||
assert_eq!(prefs.duplicate_offset, 1000.0);
|
||||
assert_eq!(prefs.undo_limit, deckcraft_engine::MAX_UNDO_LIMIT);
|
||||
assert_eq!(prefs.recovery_minutes, 0.1);
|
||||
assert_eq!(prefs.author.chars().count(), 256);
|
||||
// A zero or negative zoom means "fit".
|
||||
std::fs::write(d.join(UI_FILE), r#"{"zoom": 0}"#).unwrap();
|
||||
assert_eq!(load(&d).0.unwrap().zoom, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_round_trips_and_leaves_no_temporary_file() {
|
||||
let d = dir("save");
|
||||
let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() };
|
||||
let prefs = Prefs { author: "Ann".into(), ..Default::default() };
|
||||
save(&d.join("nested"), &ui, &prefs);
|
||||
let (u, p) = load(&d.join("nested"));
|
||||
assert_eq!(u.unwrap().zoom, Some(1.5));
|
||||
assert_eq!(p.unwrap().author, "Ann");
|
||||
let names: Vec<String> =
|
||||
std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}");
|
||||
// A settings folder that is a file: nothing saved, no panic.
|
||||
std::fs::write(d.join("blocked"), "file").unwrap();
|
||||
save(&d.join("blocked"), &ui, &prefs);
|
||||
}
|
||||
|
||||
/// The window opens with the worst `ui.json` values once they are sanitized; a zoom of
|
||||
/// 10,000 % used to panic in egui and 1e30 exhausted memory at start-up.
|
||||
#[test]
|
||||
fn the_window_opens_with_hostile_saved_state() {
|
||||
let hostile = UiState {
|
||||
zoom: Some(1.0e30),
|
||||
notes_height: 1.0e30,
|
||||
thumbs_width: -1.0e30,
|
||||
pane: Some("format".into()),
|
||||
pane_width: 1.0e30,
|
||||
..Default::default()
|
||||
};
|
||||
for ui in [hostile.clone().sanitized(), UiState { zoom: Some(1.0e4), ..Default::default() }] {
|
||||
let mut app = crate::SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());
|
||||
// The second case is not sanitized: the canvas keeps a stray zoom in range itself.
|
||||
app.ui = ui;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut crate::SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().canvas_scale <= *crate::ZOOM_RANGE.end() + 1e-3, "{}", h.state().canvas_scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-18
@@ -53,6 +53,8 @@ pub struct Show {
|
||||
notes_px: f32,
|
||||
timings: Vec<(usize, f64)>,
|
||||
last_move: f64,
|
||||
/// The last trackpad flick or mouse-wheel notch, so one gesture turns one page.
|
||||
last_swipe: f64,
|
||||
pub ended: bool,
|
||||
/// The slide whose media was last started (auto-play runs once per visit).
|
||||
media_slide: Option<usize>,
|
||||
@@ -199,6 +201,7 @@ impl Show {
|
||||
notes_px: NOTES_DEFAULT,
|
||||
timings: vec![],
|
||||
last_move: 0.0,
|
||||
last_swipe: f64::NEG_INFINITY,
|
||||
ended: false,
|
||||
media_slide: None,
|
||||
last_viewed: None,
|
||||
@@ -374,6 +377,14 @@ fn fit(avail: Rect, doc: &Presentation) -> Rect {
|
||||
|
||||
/// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective
|
||||
/// cards subdivided so their pictures stay straight) with per-vertex opacity.
|
||||
/// Where presenter view's "Next" preview goes: the column's width below its label, kept inside
|
||||
/// the top 60% of the column. A portrait slide used to get the full width and a height many
|
||||
/// times the window (a white slab over the notes and past the window edge).
|
||||
fn next_rect(right: Rect, doc: &Presentation) -> Rect {
|
||||
let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6));
|
||||
Rect::from_min_size(area.min, fit(area, doc).size())
|
||||
}
|
||||
|
||||
fn quad_mesh(tex: Option<egui::TextureId>, rect: Rect, l: &Layer, color: Color32) -> Mesh {
|
||||
let mut m = match tex {
|
||||
Some(id) => Mesh::with_texture(id),
|
||||
@@ -399,7 +410,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option<usize>, t
|
||||
let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() };
|
||||
let tex = |i: usize, part: SlidePart, name: &str| {
|
||||
let img = deckcraft_render::render_slide_part(doc, i, part, &opts);
|
||||
ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)
|
||||
crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false))
|
||||
};
|
||||
[
|
||||
from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")),
|
||||
@@ -605,6 +616,24 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm {
|
||||
actions.push(show.state.prev(&doc));
|
||||
}
|
||||
// A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick
|
||||
// there stays on the slide. Pinch zoom is not a show gesture.
|
||||
if !show.pen
|
||||
&& let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta))
|
||||
&& now - show.last_swipe > 0.35
|
||||
{
|
||||
show.last_swipe = now;
|
||||
if next {
|
||||
if show.ended {
|
||||
keep = false;
|
||||
} else {
|
||||
actions.push(show.state.next(&doc));
|
||||
}
|
||||
} else {
|
||||
show.ended = false;
|
||||
actions.push(show.state.prev(&doc));
|
||||
}
|
||||
}
|
||||
// Apply input first: a step started by this click must restart the clock before it is read.
|
||||
for a in actions.drain(..) {
|
||||
if !show.apply(a, &doc, now) {
|
||||
@@ -649,7 +678,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let painter = ui.painter_at(full);
|
||||
painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK);
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32);
|
||||
// A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit
|
||||
// (drawn stretched to the screen).
|
||||
let size = crate::textures::fit_size(
|
||||
((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32),
|
||||
crate::textures::max_side(&ctx),
|
||||
);
|
||||
if show.ended {
|
||||
painter.text(
|
||||
pos2(full.center().x, full.min.y + 40.0),
|
||||
@@ -697,10 +731,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
};
|
||||
let tex = match show.tex.take() {
|
||||
Some((_, mut h)) => {
|
||||
h.set(ci, egui::TextureOptions::LINEAR);
|
||||
crate::textures::replace(&ctx, &mut h, ci);
|
||||
h
|
||||
}
|
||||
None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR),
|
||||
None => crate::textures::upload(&ctx, "show", ci),
|
||||
};
|
||||
show.tex = Some((key, tex.clone()));
|
||||
Some(tex)
|
||||
@@ -753,10 +787,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let ci = crate::textures::to_color_image(&img, false);
|
||||
let frame = match tr.morph.take() {
|
||||
Some(mut h) => {
|
||||
h.set(ci, egui::TextureOptions::LINEAR);
|
||||
crate::textures::replace(&ctx, &mut h, ci);
|
||||
h
|
||||
}
|
||||
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
|
||||
None => crate::textures::upload(&ctx, "morph", ci),
|
||||
};
|
||||
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
tr.morph = Some(frame);
|
||||
@@ -1684,7 +1718,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi
|
||||
i,
|
||||
&RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() },
|
||||
);
|
||||
let tex = ctx.load_texture(format!("show-grid-{i}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
|
||||
let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false));
|
||||
show.thumbs.push((i, tex.clone()));
|
||||
Some(tex)
|
||||
}
|
||||
@@ -1696,16 +1730,17 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
if let Some(n) = next
|
||||
&& show.next_tex.as_ref().is_none_or(|(k, _)| *k != n)
|
||||
{
|
||||
// A very tall slide shape would otherwise ask for a picture taller than the GPU's limit.
|
||||
let next_size = crate::textures::fit_size(
|
||||
(480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
|
||||
crate::textures::max_side(ctx),
|
||||
);
|
||||
let img = deckcraft_render::render_slide(
|
||||
doc,
|
||||
n,
|
||||
&RenderOpts {
|
||||
scale: 480.0 / doc.slide_size.width.max(1.0),
|
||||
size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)),
|
||||
..Default::default()
|
||||
},
|
||||
&RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() },
|
||||
);
|
||||
show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)));
|
||||
show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false))));
|
||||
}
|
||||
let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone());
|
||||
let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default();
|
||||
@@ -1797,10 +1832,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
if let Some(c) = show.blank {
|
||||
ui.painter().rect_filled(cr, CornerRadius::ZERO, c);
|
||||
}
|
||||
let nr = Rect::from_min_size(
|
||||
right.min + vec2(0.0, 22.0),
|
||||
vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32),
|
||||
);
|
||||
let nr = next_rect(right, doc);
|
||||
ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170));
|
||||
match &next_tex {
|
||||
Some(t) => {
|
||||
@@ -1963,7 +1995,7 @@ mod tests {
|
||||
s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let ctx = egui::Context::default();
|
||||
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
|
||||
let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED));
|
||||
let mut show = Show::new(&doc, 0, false, false);
|
||||
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone()));
|
||||
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
|
||||
@@ -2197,4 +2229,21 @@ mod tests {
|
||||
show.push_slide_number('a', 1.0);
|
||||
assert!(show.goto.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_preview_fits_its_column_whatever_the_slide_shape() {
|
||||
let right = Rect::from_min_size(pos2(700.0, 100.0), vec2(400.0, 600.0));
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
for (w, h) in [(72, 4032), (4032, 72), (960, 540)] {
|
||||
s.execute("design.slideSize", &json!({"w": w, "h": h})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let r = next_rect(right, &doc);
|
||||
assert!(right.contains_rect(r), "{w}x{h}: {r:?}");
|
||||
let aspect = r.height() / r.width();
|
||||
assert!((aspect - h as f32 / w as f32).abs() / (h as f32 / w as f32) < 0.01, "{w}x{h}: {r:?}");
|
||||
}
|
||||
// 16:9 still takes the column's full width.
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
assert_eq!(next_rect(right, &doc).width(), 400.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() {
|
||||
app.ui.zoom = None;
|
||||
}
|
||||
let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round();
|
||||
let pct = (app.canvas_scale * 100.0).round();
|
||||
let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click()));
|
||||
if lbl.clicked() {
|
||||
let _ = app.run("view.zoomDialog", json!({}));
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Textures stay within the renderer's `max_texture_side` (2048 in the test harness, as on many
|
||||
//! GPUs and software fallbacks): bigger ones panicked in egui. Slide show on a big screen,
|
||||
//! thumbnails, master views and presenter view of a very tall slide.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness(session: Session, size: egui::Vec2) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
egui_kittest::Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The tallest slide allowed (1 × 56): its thumbnails and presenter-view preview are tens of
|
||||
/// thousands of pixels high at their usual widths.
|
||||
fn tall_deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.slideSize", &json!({"w": 72, "h": 4032})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_show_on_a_screen_wider_than_the_texture_limit() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let mut h = harness(s, egui::vec2(2600.0, 1500.0));
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("show.start", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().show.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_tall_slide_in_the_editor_views_and_presenter_view() {
|
||||
let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0));
|
||||
h.run_steps(3);
|
||||
for (cmd, p) in [("view.sorter", json!({})), ("view.normal", json!({})), ("view.slideMaster", json!({})), ("view.closeMaster", json!({}))] {
|
||||
let _ = h.state_mut().run(cmd, p);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.state_mut().run("show.presenter", json!({"on": true})).unwrap();
|
||||
h.state_mut().run("show.start", json!({"from": 0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().show.is_some());
|
||||
}
|
||||
|
||||
/// Presenter view of a portrait slide: the "Next" preview keeps the slide's shape inside its
|
||||
/// column. It took the column's full width and 56 times that in height, a white slab over the
|
||||
/// notes and past the window.
|
||||
#[test]
|
||||
fn presenter_view_keeps_a_portrait_next_slide_in_its_column() {
|
||||
let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0));
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("show.presenter", json!({"on": true})).unwrap();
|
||||
h.state_mut().run("show.start", json!({"from": 0})).unwrap();
|
||||
h.run_steps(4);
|
||||
let img = h.render().unwrap();
|
||||
// Right of the Next column's narrow preview, below where a landscape one would end.
|
||||
let px = img.get_pixel(1000, 600).0;
|
||||
assert!(px[0] < 128 && px[1] < 128 && px[2] < 128, "the Next preview spills over the column: {px:?}");
|
||||
}
|
||||
+146
-11
@@ -89,7 +89,7 @@ impl Textures {
|
||||
let id = g.id;
|
||||
s.shapes = vec![g];
|
||||
let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?;
|
||||
let tex = ctx.load_texture(format!("smartart-{key}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
||||
let tex = upload(ctx, format!("smartart-{key}"), to_color_image(&img, false));
|
||||
if self.smartart.len() > 300 {
|
||||
self.smartart.clear();
|
||||
}
|
||||
@@ -115,6 +115,66 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
|
||||
ColorImage::new([w, h], px)
|
||||
}
|
||||
|
||||
/// The largest texture side the renderer (GPU or software fallback) takes; egui panics on
|
||||
/// a bigger texture.
|
||||
pub fn max_side(ctx: &egui::Context) -> u32 {
|
||||
ctx.input(|i| i.max_texture_side).clamp(1, u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// `(w, h)` scaled down, keeping its shape, so neither side exceeds `max` (and none is 0).
|
||||
/// Render sizes go through this so a huge display, DPI or slide shape doesn't render a
|
||||
/// picture the GPU can't hold.
|
||||
pub fn fit_size((w, h): (u32, u32), max: u32) -> (u32, u32) {
|
||||
let (w, h, max) = (w.max(1), h.max(1), max.max(1));
|
||||
let big = w.max(h);
|
||||
if big <= max {
|
||||
return (w, h);
|
||||
}
|
||||
let k = max as f64 / big as f64;
|
||||
(((w as f64 * k).floor() as u32).clamp(1, max), ((h as f64 * k).floor() as u32).clamp(1, max))
|
||||
}
|
||||
|
||||
/// `img` shrunk by whole-pixel box averaging until both sides fit `max`.
|
||||
pub fn shrink_to(img: ColorImage, max: u32) -> ColorImage {
|
||||
let [w, h] = img.size;
|
||||
let max = max.max(1) as usize;
|
||||
if w <= max && h <= max {
|
||||
return img;
|
||||
}
|
||||
let f = w.max(h).div_ceil(max);
|
||||
let (nw, nh) = (w.div_ceil(f).max(1), h.div_ceil(f).max(1));
|
||||
let mut px = Vec::with_capacity(nw * nh);
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let mut acc = [0u32; 4];
|
||||
let mut n = 0u32;
|
||||
for sy in y * f..((y + 1) * f).min(h) {
|
||||
for sx in x * f..((x + 1) * f).min(w) {
|
||||
if let Some(c) = img.pixels.get(sy * w + sx) {
|
||||
for (a, v) in acc.iter_mut().zip(c.to_array()) {
|
||||
*a += v as u32;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = n.max(1);
|
||||
px.push(egui::Color32::from_rgba_premultiplied((acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8, (acc[3] / n) as u8));
|
||||
}
|
||||
}
|
||||
ColorImage::new([nw, nh], px)
|
||||
}
|
||||
|
||||
/// Upload a texture, shrunk to the renderer's limit if need be.
|
||||
pub fn upload(ctx: &egui::Context, name: impl Into<String>, img: ColorImage) -> TextureHandle {
|
||||
ctx.load_texture(name, shrink_to(img, max_side(ctx)), TextureOptions::LINEAR)
|
||||
}
|
||||
|
||||
/// Replace a texture's picture, shrunk to the renderer's limit if need be.
|
||||
pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
|
||||
tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR);
|
||||
}
|
||||
|
||||
fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||
}
|
||||
@@ -127,6 +187,14 @@ fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix the preview playhead into the canvas cache key. Zero means "no preview", so a playhead of
|
||||
/// 0s still misses the at-rest texture (entrances start hidden).
|
||||
fn anim_key(anim: Option<(&deckcraft_anim::Timeline, f64)>) -> u64 {
|
||||
let Some((_, t)) = anim else { return 0 };
|
||||
let ms = if t.is_finite() { (t * 1000.0).clamp(0.0, 1.0e12) as u64 } else { 0 };
|
||||
ms.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
|
||||
fn threads() -> u16 {
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
|
||||
}
|
||||
@@ -148,6 +216,7 @@ impl Textures {
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
anim: Option<(&deckcraft_anim::Timeline, f64)>,
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
@@ -164,13 +233,15 @@ impl Textures {
|
||||
Some((i, d)) if i == index => estimate.union(d),
|
||||
_ => estimate,
|
||||
};
|
||||
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
|
||||
// The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui.
|
||||
let max_side = max_side(ctx);
|
||||
let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side);
|
||||
let key = Key {
|
||||
slide: Arc::as_ptr(&slide) as usize,
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
size,
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64,
|
||||
extra: extra ^ grayscale as u64 ^ anim_key(anim),
|
||||
want,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
@@ -180,8 +251,18 @@ impl Textures {
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let render = |region: Region| {
|
||||
let opts =
|
||||
RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() };
|
||||
let state_of = |id: ShapeId| anim.map(|(tl, time)| crate::preview::shape_state_at(tl, id, time)).unwrap_or_default();
|
||||
let state: Option<&dyn Fn(ShapeId) -> deckcraft_render::ShapeState> = if anim.is_some() { Some(&state_of) } else { None };
|
||||
let opts = RenderOpts {
|
||||
scale,
|
||||
edit,
|
||||
gif_times,
|
||||
threads: threads(),
|
||||
offset: region.offset(),
|
||||
size: Some(region.size()),
|
||||
state,
|
||||
..Default::default()
|
||||
};
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
};
|
||||
let (mut img, drawn) = render(want);
|
||||
@@ -190,7 +271,8 @@ impl Textures {
|
||||
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
|
||||
// region it calls for is known (and is next frame's key): render again only if it shows
|
||||
// more than this render covered.
|
||||
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
|
||||
let key =
|
||||
Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key };
|
||||
if !region.contains(&key.want) {
|
||||
region = key.want;
|
||||
img = render(region).0;
|
||||
@@ -201,10 +283,10 @@ impl Textures {
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
Some(mut e) => {
|
||||
e.tex.set(ci, TextureOptions::LINEAR);
|
||||
replace(ctx, &mut e.tex, ci);
|
||||
e.tex
|
||||
}
|
||||
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
|
||||
None => upload(ctx, "slide-canvas", ci),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
@@ -250,9 +332,10 @@ impl Textures {
|
||||
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
|
||||
}
|
||||
*budget -= 1;
|
||||
let scale = width_px as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
|
||||
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
|
||||
let (tw, th) = fit_size((width_px, h), max_side(ctx));
|
||||
let scale = tw as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
|
||||
let tex = upload(ctx, format!("thumb-{index}"), to_color_image(&img, false));
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
@@ -266,3 +349,55 @@ impl Textures {
|
||||
Some(tex)
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits.
|
||||
pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region {
|
||||
fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) {
|
||||
if *len > max {
|
||||
let hi = *start as i64 + (*len - max) as i64;
|
||||
*start = (vis as i64).clamp(*start as i64, hi) as i32;
|
||||
*len = max;
|
||||
}
|
||||
}
|
||||
axis(&mut r.x, &mut r.w, visible.x, max);
|
||||
axis(&mut r.y, &mut r.h, visible.y, max);
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canvas_textures_stay_within_the_texture_limit() {
|
||||
let r = Region { x: -100, y: -50, w: 5000, h: 1000 };
|
||||
let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048);
|
||||
assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000));
|
||||
// Visible past the far end: the cut is pinned there.
|
||||
let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048);
|
||||
assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048));
|
||||
assert_eq!(fit_texture(r, r, 8192), r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_sizes_and_pictures_shrink_to_the_limit_keeping_their_shape() {
|
||||
assert_eq!(fit_size((195, 10920), 2048), (36, 2048));
|
||||
assert_eq!(fit_size((3000, 1500), 2048), (2048, 1024));
|
||||
assert_eq!(fit_size((800, 600), 2048), (800, 600));
|
||||
assert_eq!(fit_size((0, 0), 0), (1, 1));
|
||||
// A 5000 × 3 picture: half red, half blue, averaged down to 1667 × 1.
|
||||
let img = ColorImage::new(
|
||||
[5000, 3],
|
||||
(0..3)
|
||||
.flat_map(|y| (0..5000).map(move |x| (x, y)))
|
||||
.map(|(x, _)| if x < 2500 { egui::Color32::RED } else { egui::Color32::BLUE })
|
||||
.collect(),
|
||||
);
|
||||
let small = shrink_to(img, 2048);
|
||||
assert_eq!(small.size, [1667, 1]);
|
||||
assert_eq!(small.pixels[0], egui::Color32::RED);
|
||||
assert_eq!(small.pixels[1666], egui::Color32::BLUE);
|
||||
let ok = ColorImage::filled([10, 10], egui::Color32::WHITE);
|
||||
assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,20 +278,22 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click());
|
||||
let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th));
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = ((tw * ppp) as u32).max(1);
|
||||
let px = crate::textures::fit_size(
|
||||
(((tw * ppp) as u32).max(1), ((tw * ppp) as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
|
||||
crate::textures::max_side(ui.ctx()),
|
||||
);
|
||||
let pic = deckcraft_render::render_layout(
|
||||
&doc,
|
||||
mi,
|
||||
li,
|
||||
&deckcraft_render::RenderOpts {
|
||||
scale: px as f64 / doc.slide_size.width.max(1.0),
|
||||
scale: px.0 as f64 / doc.slide_size.width.max(1.0),
|
||||
edit: true,
|
||||
size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)),
|
||||
size: Some(px),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let tex =
|
||||
ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false));
|
||||
ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let selected = match (target, li) {
|
||||
(Target::Master { master }, None) => master == mi,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (animation bounce end and repeat round-trip; rewind still #77) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Animations ▸ Preview plays the current slide on the editor canvas; previous: major, transitions measured frame by frame) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Transitions and animations are what make a deck a presentation, and the part of PowerPoint people
|
||||
notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)
|
||||
@@ -74,6 +75,9 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Animation Pane: list, reorder, remove | yes | yes |
|
||||
| Animation Pane: advanced timeline (bars you drag) | yes | yes: bars from the show schedule, drag the body for delay, the right edge for duration, seconds ruler (`animpane_tests`) |
|
||||
| Preview in the editor (current effect, from selected) | yes | partly |
|
||||
| Animation Pane: advanced timeline (bars you drag) | yes | **no** |
|
||||
| Preview on the editor canvas (the current slide) | yes | yes |
|
||||
| Preview from the selected effect | yes | no |
|
||||
| Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) |
|
||||
| Multiple animations on one object | yes | yes |
|
||||
| Round trip of the timing tree | yes | flattened to the pane list on read, rebuilt on write (`every_animation_effect_survives`) |
|
||||
@@ -83,6 +87,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Work | Hours |
|
||||
|---|---|
|
||||
| Preview from selected, effect dialog depth | 4–6 |
|
||||
| Animation Pane timeline, preview from the selected effect, effect dialog depth | 4–7 |
|
||||
| The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 |
|
||||
| Rewind and other effect options on round trip (#77) | 1–2 |
|
||||
| Transition sounds: the rest of PowerPoint's built-in list (Applause, Arrow, Bomb…) | 1 |
|
||||
@@ -97,6 +102,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
|
||||
| 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options |
|
||||
| 2026-10-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked |
|
||||
| 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |
|
||||
|
||||
@@ -73,7 +73,8 @@ effects project cards through a perspective camera and `anim::layer_mesh` subdiv
|
||||
textures stay straight; soft edges are per-corner opacities. The show renders the slides once per
|
||||
transition and only composites cached textures each frame; `render::composite_transition` is the
|
||||
CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape
|
||||
comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and
|
||||
comes from `anim::timeline` each frame. Animations ▸ Preview plays that same state on the editor
|
||||
canvas for the current slide, then draws the slide at rest. PDF export renders each slide at print resolution and
|
||||
overlays an invisible real-text layer with embedded font subsets.
|
||||
|
||||
## File I/O
|
||||
@@ -105,5 +106,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
| 2026-10-14 | minor | Insert › Date and Time dialog (`insert.dateTime` with no keys). 28 dialogs; command count stays 226 |
|
||||
| 2026-10-14 | minor | Command registry is 227 (`design.effects`). Dialogs stay 27. The test-count line stays 504 |
|
||||
| 2026-10-14 | minor | Command registry counted at 236, including `master.preserve` and `master.placeholders`. Dialogs stay 27. The test-count line stays 504. The menu measurement and the d556ff9 snapshot still count 226 |
|
||||
| 2026-10-13 | trivial | Animation preview draws the current slide's timeline on the editor canvas |
|
||||
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
|
||||
@@ -21,9 +21,12 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) |
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show: digits then Enter go to that slide, G is See All Slides, Z arms zoom, T pauses the presenter timer, R resets it, `[` and `]` change notes size, Esc clears a slide number, the grid and zoom before it leaves the show) |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show; Escape stops the canvas animation preview) |
|
||||
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
||||
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
||||
|
||||
+14
-2
@@ -25,6 +25,9 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview plays on the editor canvas; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page in the editor and the show; pinch-zoom remains; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -53,6 +56,7 @@ revision history.
|
||||
| 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) |
|
||||
| 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) |
|
||||
| 10 | Animation Pane: thin preview (timeline done); 64 of ~190 effects | Feature | B (pane) | 8–14 | [animation-parity.md](animation-parity.md) |
|
||||
| 10 | Animation Pane: no timeline; preview is the whole slide, not from the selected effect; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) |
|
||||
| 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) |
|
||||
| 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) |
|
||||
| 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
@@ -252,7 +256,10 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Estimate:** 6–10 h.
|
||||
|
||||
### Animation Pane, effect count and preview
|
||||
Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**.
|
||||
Animations ▸ Preview and the pane's Play All play the current slide's animations on the editor
|
||||
canvas (Escape or a click stops them). Still open: the advanced timeline, starting from the
|
||||
selected effect, and most of the effect gallery. Detail in
|
||||
[animation-parity.md](animation-parity.md). 10–16 h; the pane's timeline is a **beta blocker**.
|
||||
|
||||
### Interactive decks: media-action links, audio-triggered effects and click boxes
|
||||
- **Evidence:** the slide show plays click-triggered animations, keeps each slide's state across
|
||||
@@ -380,7 +387,9 @@ Detail in [hardware-parity.md](hardware-parity.md).
|
||||
or wgpu path).
|
||||
- **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy
|
||||
FilmCraft's hardware decode).
|
||||
- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h.
|
||||
- **No pen pressure or tilt for ink**; no pinch-zoom (it does not keep the point under the pointer). A two-finger twist does rotate the selected shapes. 3–6 h.
|
||||
- **No pen pressure or tilt for ink**; no pinch-zoom. A trackpad swipe turns the page when the
|
||||
slide fits the window, and during the show. 2–4 h for pinch-zoom.
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -466,6 +475,9 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web |
|
||||
| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds |
|
||||
| 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections |
|
||||
| 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects |
|
||||
| 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open |
|
||||
| 2026-10-14 | minor | A trackpad swipe (and the mouse wheel, in the show) turns the page. Pinch-zoom remains |
|
||||
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
|
||||
| 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Hardware parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page; pinch-zoom remains; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
|
||||
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
|
||||
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
|
||||
@@ -44,7 +46,9 @@ because it mostly follows from hardware use.
|
||||
|---|---|---|
|
||||
| Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes |
|
||||
| Mouse and trackpad scroll | yes | yes |
|
||||
| Pinch to zoom, two-finger gestures | yes | no |
|
||||
| Pinch to zoom, two-finger gestures | yes | pinch zoom does not keep the point under the pointer; a two-finger twist rotates the selected shapes |
|
||||
| Pinch to zoom | yes | no |
|
||||
| Two-finger swipe between slides | yes | yes: when the slide fits the window, and during the show. A zoomed slide still pans |
|
||||
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
|
||||
| Touch screens (Windows) | yes | basic pointer only |
|
||||
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
|
||||
@@ -73,5 +77,7 @@ outlines every frame (#67, fix open), and the decoders run in software.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size |
|
||||
| 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged |
|
||||
| 2026-10-12 | minor | IME input methods (#101) |
|
||||
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PowerPoint feature checklist
|
||||
|
||||
> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 80%** over 191 features.
|
||||
@@ -46,9 +46,14 @@ The summary between `<!-- SUMMARY -->
|
||||
| Transitions | 100% |
|
||||
| Animations | 79% |
|
||||
| Slide show | 68% |
|
||||
| Transitions | 92% |
|
||||
| Animations | 85% |
|
||||
| Slide show | 66% |
|
||||
| Review | 60% |
|
||||
| Draw | 33% |
|
||||
| Undo & clipboard | 100% |
|
||||
| Draw | 50% |
|
||||
| Undo & clipboard | 88% |
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
@@ -226,7 +231,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Animations | By-paragraph text builds | P1 | D |
|
||||
| Animations | Triggers | P2 | D |
|
||||
| Animations | Animation Painter | P2 | D |
|
||||
| Animations | Preview in editor | P1 | P |
|
||||
| Animations | Preview in editor | P1 | D |
|
||||
| Slide show | Full-screen show from start / current | P0 | D |
|
||||
| Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D |
|
||||
| Slide show | Presenter view (notes, next slide, timer) | P0 | P |
|
||||
@@ -242,7 +247,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Review | Compare presentations | P2 | M |
|
||||
| Review | Track changes / version history | P2 | M |
|
||||
| Draw | Ink pens, highlighter, eraser | P1 | P |
|
||||
| Draw | Ink to shape / text | P2 | M |
|
||||
| Draw | Ink to shape / text | P2 | P |
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D |
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (custom show playback; previous: trivial, presenter timer) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (go to slide shows the typed number; previous: trivial, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (canvas animation preview; beta item 9 keeps the timeline; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
|
||||
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
|
||||
@@ -82,6 +83,8 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx`
|
||||
| 8 | Charts: data grid, elements | 6–10 | Features |
|
||||
| 9 | Animation Pane timeline, preview, effect options | 5–8 | Features |
|
||||
| 10 | Presenter view on the right display, swap displays | 2–4 | Hardware, Slide show |
|
||||
| 9 | Animation Pane timeline, preview from the selected effect, effect options | 4–7 | Features |
|
||||
| 10 | Presenter view on the right display, timer controls | 3–5 | Hardware, Slide show |
|
||||
| 11 | Native macOS menu bar, Format Shape pane depth, start screen and window chrome fixes | 13–22 | UI/UX 60 → 75% |
|
||||
| 12 | Performance baseline and the obvious fixes | 4–8 | Performance |
|
||||
| **Total** | | **~120–210** | Ready ~55% → ~75% |
|
||||
@@ -164,5 +167,6 @@ plan above.
|
||||
| 2026-10-14 | trivial | Present workflow notes custom show playback |
|
||||
| 2026-10-14 | trivial | Present workflow notes presenter timer pause/reset, clock and notes size. Display placement stays open |
|
||||
| 2026-10-14 | trivial | Present workflow notes See All Slides and zoom |
|
||||
| 2026-10-13 | trivial | Canvas animation preview landed; beta item 9 keeps the timeline and preview from the selected effect |
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
|
||||
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (presenter timer pause/reset, clock and notes size; previous: minor, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (custom show playback; previous: minor, presenter timer pause/reset) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (go to slide shows the number while it is typed; previous: minor, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe or mouse wheel turns the page during the show; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
|
||||
rehearsal and recording. Transition and animation playback are in
|
||||
@@ -23,6 +24,8 @@ ready** (estimated), **10–16 h** to parity.
|
||||
| Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | |
|
||||
| Next/previous by click, arrows, Space, Page Up/Down, Enter, Backspace | yes | yes | #17 reported arrows not working in v0.3.0 AppImage; recheck |
|
||||
| Go to slide (number + Enter), first/last | yes | yes: the digits show as "Go to slide …" until Enter, Backspace, Esc, or 3 seconds | Slide numbers are 1-based; 0 and a number past the end do not move |
|
||||
| Next/previous by trackpad swipe or mouse wheel | yes | yes | One flick turns one page. The pen ignores the flick |
|
||||
| Go to slide (number + Enter), first/last | yes | yes | |
|
||||
| Black / white screen (B / W) | yes | yes | |
|
||||
| End screen, Esc to leave | yes | yes | |
|
||||
| Step clock: a click after a long pause plays its step (#11) | yes | yes | |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (Preview in editor re-scored done; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
|
||||
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
|
||||
@@ -28,6 +29,7 @@ number.
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 39%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md) |
|
||||
| **Feature breadth** (weighted checklist) | **80%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md); Preview in editor re-scored 2026-10-13 |
|
||||
| Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists |
|
||||
| **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) |
|
||||
| **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
@@ -80,7 +82,7 @@ The weights are what those users actually depend on, written down so the total c
|
||||
|
||||
| Dimension | Weight | Ready | Hours to full | Doc | Evidence |
|
||||
|---|---:|---:|---|---|---|
|
||||
| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 79% measured. Depth lags: 37 of 191 rows are partial, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) |
|
||||
| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 80% measured. Depth lags: partial rows remain, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) |
|
||||
| File formats | 20% | ~58% | 60–110 | [file-format-parity.md](file-format-parity.md) | PPTX reads and writes every main part and keeps unknown objects; files open in PowerPoint without repair. But it has never met a corpus of real decks, EMF/WMF/SVG pictures don't decode, comments/media/rewind settings are lost on round trip, and there is no `.ppt`, `.pptm`, `.odp`, video or GIF export |
|
||||
| UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 |
|
||||
| Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) |
|
||||
@@ -215,12 +217,14 @@ it is the Features row of the dimension table. Automation and Platforms are scor
|
||||
| Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) |
|
||||
| Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) |
|
||||
| Slide show and presenter view | 8 | 68% | ~60% | 10–16 | Second-display placement, laser from presenter view, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) |
|
||||
| Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview from the selected effect, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) |
|
||||
| Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) |
|
||||
| Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) |
|
||||
| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) |
|
||||
| Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) |
|
||||
| Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) |
|
||||
| Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) |
|
||||
| **Total (feature areas)** | 110 | **79%** | **~62%** | **~140–240** | | |
|
||||
| **Total (feature areas)** | 110 | **80%** | **~62%** | **~140–240** | | |
|
||||
|
||||
## What works today (evidence)
|
||||
|
||||
@@ -340,6 +344,7 @@ scored done (16 open issues).
|
||||
| 2026-10-13 | trivial | Essentials discoverability note: the command palette's Export failure (#79) is fixed; the ×0.95 discount stays for the menu bar and start screen |
|
||||
| 2026-10-14 | minor | Design ▸ Effects noted under what works today. Percentages stay 79% / ~60% / ~55%. The d556ff9 snapshot and the menu measurement stay at 226 commands; the live registry is 227 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages unchanged |
|
||||
| 2026-10-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% |
|
||||
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
|
||||
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview on the editor canvas; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Format Shape fill transparency follows the selection; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -28,6 +31,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||
@@ -88,11 +92,14 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
|---|---|---|
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
|
||||
| Format Background pane | yes | yes |
|
||||
| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master |
|
||||
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||
| Comments pane (threads) | yes | yes: every slide, with Previous and Next on the Review tab |
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing, play the current slide on the canvas; no timeline |
|
||||
| Comments pane (threads) | yes | yes |
|
||||
| Design Ideas (Designer) | yes (cloud) | no |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Size and Position, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Photo Album, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
@@ -131,6 +138,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Drag and drop of files and between thumbnails | yes | yes |
|
||||
| Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes |
|
||||
| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) |
|
||||
| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 |
|
||||
| Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) |
|
||||
|
||||
## Keyboard shortcuts
|
||||
@@ -220,6 +228,9 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout |
|
||||
| 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip |
|
||||
| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas |
|
||||
| 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss |
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
|
||||
Reference in New Issue
Block a user