Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 9b61a5d20e Follow #432: start Finder-opened PPSX slide shows
Use the system opener for macOS open-document events, matching command-line slideshow startup. Cover launch-time and later opens, error preservation and slideshow selection.
2026-10-21 10:40:00 -06:00
19 changed files with 120 additions and 339 deletions
-20
View File
@@ -1,20 +0,0 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
Generated
+28 -28
View File
@@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"arboard",
"cpal",
@@ -912,7 +912,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -921,7 +921,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -930,14 +930,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -947,14 +947,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -979,7 +979,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -991,7 +991,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1002,7 +1002,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"kurbo",
"serde",
@@ -1010,7 +1010,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1019,7 +1019,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1028,7 +1028,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1036,11 +1036,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
version = "0.10.0"
version = "0.9.0"
[[package]]
name = "deckcraft-mcp"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1050,7 +1050,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1068,7 +1068,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo",
@@ -1077,7 +1077,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1089,15 +1089,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.10.0"
version = "0.9.0"
[[package]]
name = "deckcraft-opus"
version = "0.10.0"
version = "0.9.0"
[[package]]
name = "deckcraft-pdf"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1115,7 +1115,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1131,7 +1131,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1150,7 +1150,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1164,7 +1164,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1186,7 +1186,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1195,7 +1195,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -5733,7 +5733,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.10.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-4
View File
@@ -7,10 +7,6 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
</p>
<h1 align="center">DeckCraft</h1>
<p align="center">
+3 -5
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (macOS Finder document opening; native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **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
@@ -132,7 +132,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
@@ -208,7 +207,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft |
| 2026-10-21 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. The current default app is preserved until the user chooses DeckCraft; system-opened `.ppsx` files start their slide show |
| 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 |
@@ -243,7 +242,6 @@ app's JSON control channel (`deckcraft --control PORT`).
|---|---|---|
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
@@ -300,7 +298,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) |
| 2026-10-21 | minor | Progress log: Finder opens presentations on macOS (#138) |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
+71 -10
View File
@@ -36,17 +36,78 @@ impl AppleEvents {
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
if let Err(e) = app.open_path(p) {
log::error!("{p}: {e}");
app.set_status(format!("Couldn't open {p}: {e}"));
}
}
for event in inbox.drain() {
handle(event, app, ctx);
}
}
fn handle(event: Event, app: &mut SlideApp, ctx: &egui::Context) {
match event {
Event::Open(paths) => {
let paths: Vec<String> = paths.iter().filter_map(|p| p.to_str().map(str::to_string)).collect();
for (path, error) in app.open_from_system(&paths) {
log::error!("{path}: {error}");
app.set_status(format!("Couldn't open {path}: {error}"));
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_engine::Session;
use deckcraft_ui_egui::Services;
use serde_json::json;
use std::path::PathBuf;
fn presentation(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-apple-events-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
let mut session = Session::with_new();
session.execute("file.saveAs", &json!({"path": path})).unwrap();
path
}
#[test]
fn open_events_append_presentations_at_launch_and_while_running() {
let first = presentation("Finder launch ü.pptx");
let second = presentation("Finder second.deckcraft");
let mut app = SlideApp::new(Session::new(), Services::default());
let ctx = egui::Context::default();
handle(Event::Open(vec![first.clone()]), &mut app, &ctx);
assert_eq!(app.session.documents().len(), 1);
handle(Event::Open(vec![second.clone()]), &mut app, &ctx);
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.ui.recent.first(), second.to_str().map(str::to_string).as_ref());
std::fs::remove_file(first).unwrap();
std::fs::remove_file(second).unwrap();
}
#[test]
fn open_event_starts_only_the_last_successful_powerpoint_show() {
let show = presentation("Finder show.PPSX");
let edit = presentation("Finder edit.pptx");
let ctx = egui::Context::default();
let mut app = SlideApp::new(Session::new(), Services::default());
handle(Event::Open(vec![show.clone(), edit.clone()]), &mut app, &ctx);
assert!(app.show.is_none(), "the last file opens for editing");
handle(Event::Open(vec![show.clone()]), &mut app, &ctx);
assert!(app.show.is_some(), "a system-opened PPSX starts its show");
std::fs::remove_file(show).unwrap();
std::fs::remove_file(edit).unwrap();
}
#[test]
fn bad_open_event_reports_the_error_and_keeps_the_document() {
let mut app = SlideApp::new(Session::with_new(), Services::default());
let ctx = egui::Context::default();
handle(Event::Open(vec![PathBuf::from("/nonexistent/deckcraft-finder-missing.pptx")]), &mut app, &ctx);
assert_eq!(app.session.documents().len(), 1);
// Status is visible through the public UI control inspection.
let result = deckcraft_ui_egui::control::inspect(&app, &ctx);
assert!(result.to_string().contains("Couldn't open"), "{result}");
}
}
-42
View File
@@ -525,46 +525,4 @@ mod tests {
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
assert!(s.anim_painter.is_some());
}
fn setup_triggers() -> (Session, [u64; 4]) {
let mut s = Session::with_new();
let mut ids = [0u64; 4];
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
*slot = r.get("id").unwrap().as_u64().unwrap();
}
(s, ids)
}
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
}
#[test]
fn deleting_a_trigger_shape_clears_the_trigger() {
let (mut s, [a, b, c, d]) = setup_triggers();
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
assert_eq!(triggers(&s), vec![id(b), id(c)]);
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
}
#[test]
fn deleting_a_group_clears_triggers_of_its_children() {
let (mut s, [a, b, c, _]) = setup_triggers();
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
}
}
+1 -13
View File
@@ -572,24 +572,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
let mut before = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
let n = remove_shapes(shapes, &ids);
// Everything that is gone: the given ids plus group descendants and emptied groups.
let mut left = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
let mut gone = ids.clone();
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
// Animations of deleted shapes go too.
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
let sl = Arc::make_mut(sl);
sl.animations.retain(|a| !gone.contains(&a.shape));
// Effects triggered by a deleted shape fall back to the main sequence.
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
a.trigger = None;
}
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
}
sel.shapes.retain(|i| !ids.contains(i));
sel.text = None;
-8
View File
@@ -318,14 +318,6 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
}
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
}
}
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
+5 -24
View File
@@ -179,7 +179,7 @@ fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcr
fn index_of(s: &Session, p: &Value) -> Result<usize> {
let st = s.doc()?;
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("slide", format!("no slide {i}")));
}
@@ -187,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
let indices: Vec<usize> = match usize_param(p, "index") {
Some(i) => vec![i],
None => {
let st = s.doc()?;
@@ -240,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
} else if let Some(i) = usize_param(p, "index") {
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
} else if !st.selection.slides.is_empty() {
st.selection.slides.clone()
@@ -266,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
s.edit(|doc, sel| {
if from >= doc.slides.len() {
@@ -531,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
}
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
s.edit(|doc, _| {
let at = at.min(doc.slides.len().saturating_sub(1));
@@ -648,25 +648,6 @@ mod tests {
use crate::Session;
#[test]
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
for bad in [json!(-1), json!(1.5)] {
for (cmd, p) in [
("slide.delete", json!({"index": bad})),
("slide.duplicate", json!({"index": bad})),
("slide.move", json!({"from": bad, "to": 0})),
("section.add", json!({"at": bad})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
}
}
}
#[test]
fn duplicate_keeps_section_order_in_step_with_slides() {
let mut s = Session::with_new();
-5
View File
@@ -44,11 +44,6 @@ pub fn instance_id() -> u64 {
*ID.get_or_init(|| {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
// No PIDs and no std::time on wasm (`std::process::id()` and
// `SystemTime::now()` panic there): the browser clock stands in.
#[cfg(target_arch = "wasm32")]
h.write_u64(js_sys::Date::now() as u64);
#[cfg(not(target_arch = "wasm32"))]
h.write_u32(std::process::id());
#[cfg(not(target_arch = "wasm32"))]
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
+2 -8
View File
@@ -281,8 +281,8 @@ fn percent_decode(s: &str) -> String {
/// Resolve a relative target against a base directory into a part name.
pub fn resolve(base: &str, target: &str) -> String {
let target = percent_decode(target.split('#').next().unwrap_or(""));
let target = target.as_str();
let target = percent_decode(target);
let target = target.split('#').next().unwrap_or("");
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
for s in target.split('/') {
match s {
@@ -441,12 +441,6 @@ impl PackageOut {
mod tests {
use super::*;
#[test]
fn resolve_keeps_encoded_hash_and_strips_fragment() {
assert_eq!(resolve("ppt/slides", "../media/a%23b.png"), "ppt/media/a#b.png");
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
}
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
+4 -23
View File
@@ -413,9 +413,6 @@ struct ParaShaped {
/// Size of the paragraph's first run (spacing percentages, empty lines).
lead_size: f64,
lead_face: Arc<FontFace>,
/// Size of the paragraph mark (`a:endParaRPr`): what an empty last line is sized by.
end_size: f64,
end_face: Arc<FontFace>,
bullet: Option<(String, Style)>,
#[allow(dead_code)]
level: u8,
@@ -712,9 +709,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
faces: vec![],
props: ParaProps::default(),
lead_size: 18.0,
lead_face: face.clone(),
end_size: 18.0,
end_face: face,
lead_face: face,
bullet: None,
level: 0,
rtl: false,
@@ -746,9 +741,6 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
let lead_size = lead_style.size;
let lead_face = lead_style.face.clone();
// The paragraph mark, which ends the last line when no break does.
let end_style = make_style(ctx, &resolve::run(ctx, shape, para, &para.end_props), scale, opts.prompt_color);
let (end_size, end_face) = (end_style.size, end_style.face.clone());
// Bullet.
let bullet = match &props.bullet {
Some(Bullet::Char { char }) => {
@@ -797,7 +789,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
}
_ => None,
};
ParaShaped { cells, styles, faces, props, lead_size, lead_face, end_size, end_face, bullet, level: para.level, rtl }
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
@@ -1720,21 +1712,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
}
}
if size == 0.0 {
// PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the
// `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not
// by the paragraph's first run (#780). An empty *paragraph* already lands on
// `end_props` through `lead`, and keeps the same height here.
let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style));
let (face, sz) = match brk {
Some(st) => (&st.face, st.size),
None if rl.end >= p.cells.len() => (&p.end_face, p.end_size),
// Anything else empty (a line of tabs alone) keeps the old measure.
None => (&p.lead_face, p.lead_size),
};
let (a, d) = line_metrics(face, sz);
let (a, d) = line_metrics(&p.lead_face, p.lead_size);
asc = a;
desc = d;
size = sz;
size = p.lead_size;
}
let natural = (asc + desc).max(size * 1.0);
let lh = match props.line_spacing {
-96
View File
@@ -1467,99 +1467,3 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
assert_eq!(l.lines.len(), 2);
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
}
/// A run of `text` at `size` points.
fn sized_run(text: &str, size: f64) -> Run {
let mut r = Run::new(text);
r.props.size = Some(size);
r
}
/// A line break whose own run properties are `size` points.
fn sized_break(size: f64) -> Run {
let mut r = Run { text: String::new(), props: Default::default(), kind: deckcraft_model::text::RunKind::Break };
r.props.size = Some(size);
r
}
/// The height one line of `size`-point text takes in this shape: the yardstick the empty-line
/// tests measure against, so they do not depend on the test font's ascent and descent.
fn one_line_height(p: &Presentation, sh: &mut Shape, size: f64) -> f64 {
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs: vec![sized_run("X", size)], ..Default::default() }];
let l = lay(p, sh, 400.0, 400.0);
l.lines[0].bottom - l.lines[0].top
}
#[test]
fn an_empty_line_is_sized_by_the_break_that_ends_it() {
// PowerPoint sizes an empty line by the mark that closes it, not by the paragraph's first
// run (#780). Case A of the report: 40pt text, a 40pt break, a 10pt break, 40pt text, so
// the empty second line is a 10pt line and not a 40pt one.
let (p, mut sh) = setup("x", 400.0, 400.0);
let (h10, h40, h60, h20) = (
one_line_height(&p, &mut sh, 10.0),
one_line_height(&p, &mut sh, 40.0),
one_line_height(&p, &mut sh, 60.0),
one_line_height(&p, &mut sh, 20.0),
);
let put = |sh: &mut Shape, runs: Vec<Run>| {
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs, ..Default::default() }];
};
put(&mut sh, vec![sized_run("A1", 40.0), sized_break(40.0), sized_break(10.0), sized_run("A2", 40.0)]);
let a = lay(&p, &sh, 400.0, 400.0);
assert_eq!(a.lines.len(), 3, "{:?}", a.lines.iter().map(|l| l.top).collect::<Vec<_>>());
let empty_a = a.lines[2].top - a.lines[1].top;
assert!((empty_a - h10).abs() < 0.5, "a 10pt break should give a 10pt line ({h10}), got {empty_a}");
assert!((empty_a - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
// Case C: a 60pt break closing the empty line of a 20pt paragraph pushes the third line
// down, the opposite direction from the first run's size -- so this cannot pass by the old
// behaviour under a different font.
put(&mut sh, vec![sized_run("C1", 20.0), sized_break(20.0), sized_break(60.0), sized_run("C2", 20.0)]);
let c = lay(&p, &sh, 400.0, 400.0);
let empty_c = c.lines[2].top - c.lines[1].top;
assert!((empty_c - h60).abs() < 0.5, "a 60pt break should give a 60pt line ({h60}), got {empty_c}");
assert!((empty_c - h20).abs() > 1.0, "and not the 20pt first run's line ({h20})");
}
#[test]
fn an_empty_last_line_is_sized_by_the_paragraph_mark() {
// Case B: 40pt text and a trailing 40pt break, with the paragraph mark at 10pt. No break
// closes the empty last line, so it takes the mark's size.
let (p, mut sh) = setup("x", 400.0, 400.0);
let (h10, h40) = (one_line_height(&p, &mut sh, 10.0), one_line_height(&p, &mut sh, 40.0));
{
let t = sh.text.as_mut().unwrap();
let mut para = Paragraph { runs: vec![sized_run("B1", 40.0), sized_break(40.0)], ..Default::default() };
para.end_props.size = Some(10.0);
t.paragraphs = vec![para];
}
let l = lay(&p, &sh, 400.0, 400.0);
assert_eq!(l.lines.len(), 2, "the trailing break leaves an empty last line");
let h = l.lines[1].bottom - l.lines[1].top;
assert!((h - h10).abs() < 0.5, "a 10pt paragraph mark should give {h10}, got {h}");
assert!((h - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
}
#[test]
fn an_empty_paragraph_keeps_its_paragraph_mark_height() {
// Case D, the control: an empty *paragraph* already took `end_props` before this change,
// through `lead`, and must be unaffected by it.
let (p, mut sh) = setup("x", 400.0, 400.0);
let h10 = one_line_height(&p, &mut sh, 10.0);
{
let t = sh.text.as_mut().unwrap();
let mut empty = Paragraph::default();
empty.end_props.size = Some(10.0);
t.paragraphs = vec![
Paragraph { runs: vec![sized_run("D1", 40.0)], ..Default::default() },
empty,
Paragraph { runs: vec![sized_run("D2", 40.0)], ..Default::default() },
];
}
let l = lay(&p, &sh, 400.0, 400.0);
assert_eq!(l.lines.len(), 3);
let h = l.lines[1].bottom - l.lines[1].top;
assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}");
}
-37
View File
@@ -37,40 +37,3 @@ fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() {
assert!(h.query_all_by_role(Role::TextInput).count() < 200);
assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500);
}
/// The thumbnail pane of an 800-slide deck renders only the thumbnails in view. When every row
/// asked for one, a deck bigger than the 600-entry thumbnail cache evicted its own thumbnails before
/// the pane was done, so the pane kept rendering and flashing white forever (#969).
#[test]
fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() {
let mut h = harness(800);
h.run_steps(60);
let settled = h.state().textures.thumb_count();
assert!(settled > 3 && settled < 60, "{settled} thumbnails rendered");
// The visible thumbnails are done: more frames render nothing new.
h.run_steps(10);
assert_eq!(h.state().textures.thumb_count(), settled);
// Scrolled far down, the slides now in view get their thumbnails, and only those.
h.hover_at(egui::pos2(100.0, 450.0));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -40_000.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(60);
let scrolled = h.state().textures.thumb_count();
assert!(scrolled > settled && scrolled < 2 * settled + 10, "{scrolled} thumbnails after scrolling (was {settled})");
}
/// The same in the Slide Sorter: only the cells in view render a thumbnail (#969).
#[test]
fn the_sorter_of_a_huge_deck_renders_only_the_slides_in_view() {
let mut h = harness(800);
h.state_mut().ui.view = crate::ViewMode::Sorter;
h.run_steps(60);
let settled = h.state().textures.thumb_count();
assert!(settled > 3 && settled < 80, "{settled} thumbnails rendered");
h.run_steps(10);
assert_eq!(h.state().textures.thumb_count(), settled);
}
+2 -6
View File
@@ -203,17 +203,13 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
rects.push((i, cell));
let p = ui.painter();
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
// than the thumbnail cache does not evict its own thumbnails forever (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
match tex {
match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
need_more |= near;
need_more = true;
}
}
let id = g.doc.slides.get(i).map(|s| s.id);
+2 -6
View File
@@ -115,11 +115,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint);
}
let wpx = (img.width() * ppp).round() as u32;
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
match tex {
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
Some(tex) => {
p.image(
tex.id(),
@@ -130,7 +126,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
}
None => {
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
need_more |= near;
need_more = true;
}
}
if selected {
+1 -1
View File
@@ -111,7 +111,7 @@ if has appimage; then
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
-2
View File
@@ -1,2 +0,0 @@
sonar.projectKey=storytold_deckcraft
sonar.organization=storytold