mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47195feec | ||
|
|
a2435383b1 | ||
|
|
d01254cf2d | ||
|
|
3f399dd450 | ||
|
|
8f04a999b9 | ||
|
|
f011313a3a | ||
|
|
792829ec41 | ||
|
|
4a1d27a227 | ||
|
|
0b8a4cc23e | ||
|
|
128cb0b883 | ||
|
|
e149f32208 | ||
|
|
354087ecd5 | ||
|
|
4113361660 | ||
|
|
5a76518ddd | ||
|
|
9ad2cf3be8 | ||
|
|
63831cdfa9 | ||
|
|
2741d3236b | ||
|
|
e37267f548 | ||
|
|
a31b990d32 | ||
|
|
7571e01210 | ||
|
|
65564cfc1b | ||
|
|
fc8e811c2a | ||
|
|
e34169ff75 | ||
|
|
483a351c37 | ||
|
|
46dec4a660 | ||
|
|
9632162029 | ||
|
|
12085b1153 | ||
|
|
6d98534a27 | ||
|
|
79088b7060 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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
+40
-28
@@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -901,6 +901,7 @@ dependencies = [
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"rfd",
|
||||
@@ -911,7 +912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -920,7 +921,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -929,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -946,14 +947,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -978,7 +979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -990,7 +991,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1001,7 +1002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1009,7 +1010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1018,7 +1019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1027,7 +1028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1035,11 +1036,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1049,7 +1050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1067,7 +1068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1076,7 +1077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1088,15 +1089,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1114,7 +1115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1130,7 +1131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1149,7 +1150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1163,7 +1164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1185,7 +1186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1194,7 +1195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1631,6 +1632,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -5721,7 +5733,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</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">
|
||||
@@ -25,6 +29,9 @@
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-b7410e?style=flat-square">
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_deckcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/deckcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -132,6 +132,7 @@ 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) |
|
||||
@@ -207,6 +208,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-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 |
|
||||
@@ -241,6 +243,7 @@ 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) |
|
||||
@@ -297,6 +300,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-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 |
|
||||
|
||||
@@ -29,5 +29,12 @@ arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
cpal = "0.16"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
|
||||
# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which
|
||||
# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! macOS open-documents and quit Apple events.
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`. Handling it ourselves needs Objective-C class declarations, i.e.
|
||||
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by
|
||||
//! GridCraft, PdfCraft and PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler
|
||||
//! registered before Finder's launch event that leaves winit's delegate alone, behind a safe
|
||||
//! main-thread API.
|
||||
|
||||
use deckcraft_ui_egui::SlideApp;
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
-3
@@ -14,6 +14,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod audio;
|
||||
mod control_server;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -27,10 +29,17 @@ mod pasteboard;
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
|
||||
struct App(SlideApp);
|
||||
struct App(
|
||||
SlideApp,
|
||||
/// Files opened from Finder and quit requests (see `apple_events`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.1, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -158,6 +167,8 @@ fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>
|
||||
"--show" => show = true,
|
||||
"--version" | "-V" => return Startup::Version,
|
||||
"--help" | "-h" => return Startup::Help,
|
||||
// The process serial number older macOS adds when Finder launches the app.
|
||||
a if a.starts_with("-psn_") => {}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
@@ -234,6 +245,12 @@ fn main() -> eframe::Result {
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
"DeckCraft",
|
||||
options,
|
||||
@@ -264,7 +281,11 @@ fn main() -> eframe::Result {
|
||||
if show {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -278,7 +299,11 @@ mod tests {
|
||||
#[test]
|
||||
fn on_exit_ends_the_process_on_macos() {
|
||||
if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
|
||||
let mut app = App(SlideApp::new(Session::new(), Services::default()));
|
||||
let mut app = App(
|
||||
SlideApp::new(Session::new(), Services::default()),
|
||||
#[cfg(target_os = "macos")]
|
||||
fmv_macos_events::Inbox::default(),
|
||||
);
|
||||
println!("calling on_exit");
|
||||
eframe::App::on_exit(&mut app);
|
||||
println!("on_exit returned");
|
||||
@@ -311,4 +336,86 @@ mod tests {
|
||||
assert!(!show);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finder_process_serial_number_is_not_a_file() {
|
||||
let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]);
|
||||
assert!(matches!(launch, Startup::Run { .. }));
|
||||
if let Startup::Run { files, .. } = launch {
|
||||
assert_eq!(files, ["talk.pptx".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<string>` values of every `<array>` that follows a `<key>{key}</key>` line.
|
||||
fn plist_strings(plist: &str, key: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_array = false;
|
||||
for line in plist.lines() {
|
||||
let line = line.trim();
|
||||
if line == format!("<key>{key}</key>") {
|
||||
in_array = true;
|
||||
continue;
|
||||
}
|
||||
if in_array {
|
||||
for part in line.split("<string>").skip(1) {
|
||||
if let Some((s, _)) = part.split_once("</string>") {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
if line.ends_with("</array>") {
|
||||
in_array = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native
|
||||
/// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and
|
||||
/// Linux (`MimeType` in the desktop file). A format added to one installer must reach the
|
||||
/// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft.
|
||||
#[test]
|
||||
fn every_desktop_offers_the_same_file_types() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop");
|
||||
|
||||
let mut mac = plist_strings(plist, "CFBundleTypeExtensions");
|
||||
// The native type is declared by its UTI; its extension is in the exported type.
|
||||
mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft"));
|
||||
mac.sort();
|
||||
mac.dedup();
|
||||
assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}");
|
||||
// Every document type names a content type too, so "Change All" in Finder's Get Info works.
|
||||
let names = plist.matches("<key>CFBundleTypeName</key>").count();
|
||||
let content_types = plist.matches("<key>LSItemContentTypes</key>").count();
|
||||
assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes");
|
||||
|
||||
let mut win: Vec<String> = wxs
|
||||
.lines()
|
||||
.filter(|l| l.contains("SupportedTypes\" Name=\"."))
|
||||
.filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string))
|
||||
.collect();
|
||||
win.sort();
|
||||
|
||||
let mime_ext = |m: &str| match m {
|
||||
"application/x-deckcraft" => Some("deckcraft"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"),
|
||||
_ => None,
|
||||
};
|
||||
let mut linux: Vec<String> = desktop
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("MimeType="))
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff
|
||||
.collect();
|
||||
linux.sort();
|
||||
|
||||
assert_eq!(mac, win, "macOS and Windows offer different file types");
|
||||
assert_eq!(mac, linux, "macOS and Linux offer different file types");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,4 +525,46 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,12 +572,24 @@ 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)
|
||||
{
|
||||
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
|
||||
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;
|
||||
}
|
||||
}
|
||||
sel.shapes.retain(|i| !ids.contains(i));
|
||||
sel.text = None;
|
||||
|
||||
@@ -318,6 +318,14 @@ 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>> {
|
||||
|
||||
@@ -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 = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "index", "slide")?.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 usize_param(p, "index") {
|
||||
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
|
||||
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) = usize_param(p, "index") {
|
||||
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
|
||||
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 = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.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 = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
|
||||
let at = opt_usize_param(p, "at", "section.add")?.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,6 +648,25 @@ 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();
|
||||
|
||||
@@ -44,6 +44,11 @@ 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) {
|
||||
|
||||
@@ -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);
|
||||
let target = target.split('#').next().unwrap_or("");
|
||||
let target = percent_decode(target.split('#').next().unwrap_or(""));
|
||||
let target = target.as_str();
|
||||
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,6 +441,12 @@ 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);
|
||||
|
||||
+23
-4
@@ -413,6 +413,9 @@ 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,
|
||||
@@ -709,7 +712,9 @@ 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,
|
||||
lead_face: face.clone(),
|
||||
end_size: 18.0,
|
||||
end_face: face,
|
||||
bullet: None,
|
||||
level: 0,
|
||||
rtl: false,
|
||||
@@ -741,6 +746,9 @@ 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, ¶.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 }) => {
|
||||
@@ -789,7 +797,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, end_size, end_face, bullet, level: para.level, rtl }
|
||||
}
|
||||
|
||||
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
|
||||
@@ -1712,10 +1720,21 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
}
|
||||
}
|
||||
if size == 0.0 {
|
||||
let (a, d) = line_metrics(&p.lead_face, p.lead_size);
|
||||
// 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);
|
||||
asc = a;
|
||||
desc = d;
|
||||
size = p.lead_size;
|
||||
size = sz;
|
||||
}
|
||||
let natural = (asc + desc).max(size * 1.0);
|
||||
let lh = match props.line_spacing {
|
||||
|
||||
@@ -1467,3 +1467,99 @@ 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,3 +37,40 @@ 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);
|
||||
}
|
||||
|
||||
@@ -203,13 +203,17 @@ 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();
|
||||
match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) {
|
||||
// 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 {
|
||||
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 = true;
|
||||
need_more |= near;
|
||||
}
|
||||
}
|
||||
let id = g.doc.slides.get(i).map(|s| s.id);
|
||||
|
||||
@@ -115,7 +115,11 @@ 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;
|
||||
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
|
||||
// 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 {
|
||||
Some(tex) => {
|
||||
p.image(
|
||||
tex.id(),
|
||||
@@ -126,7 +130,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
||||
need_more = true;
|
||||
need_more |= near;
|
||||
}
|
||||
}
|
||||
if selected {
|
||||
|
||||
@@ -111,7 +111,7 @@ if has appimage; then
|
||||
if [ -z "$TOOL" ]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
if [ ! -x "$TOOL" ]; then
|
||||
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -45,6 +45,77 @@
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © the DeckCraft authors. MIT OR Apache-2.0.</string>
|
||||
|
||||
<!-- The documents DeckCraft opens, so Finder's Open With and "Change All" can offer it (the
|
||||
Apple events arrive through apps/deckcraft/src/apple_events.rs). PowerPoint stays the
|
||||
preferred app for its formats unless the user picks DeckCraft. The same set as the Windows
|
||||
installer (deckcraft.wxs) and the Linux desktop file; a test in apps/deckcraft keeps
|
||||
them equal. -->
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>DeckCraft Presentation</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>DeckCraft</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>ai.storyteller.deckcraft.presentation</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>PowerPoint Presentation</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.openxmlformats.presentationml.presentation</string>
|
||||
</array>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array><string>pptx</string></array>
|
||||
<key>CFBundleTypeMIMETypes</key>
|
||||
<array><string>application/vnd.openxmlformats-officedocument.presentationml.presentation</string></array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>PowerPoint Template</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.openxmlformats.presentationml.template</string>
|
||||
</array>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array><string>potx</string></array>
|
||||
<key>CFBundleTypeMIMETypes</key>
|
||||
<array><string>application/vnd.openxmlformats-officedocument.presentationml.template</string></array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>PowerPoint Slide Show</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.openxmlformats.presentationml.slideshow</string>
|
||||
</array>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array><string>ppsx</string></array>
|
||||
<key>CFBundleTypeMIMETypes</key>
|
||||
<array><string>application/vnd.openxmlformats-officedocument.presentationml.slideshow</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- DeckCraft's own format: a zip of a JSON manifest plus media. -->
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
@@ -89,6 +160,40 @@
|
||||
<array><string>pptx</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.openxmlformats.presentationml.template</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Open XML Presentation Template</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.presentation</string>
|
||||
<string>public.zip-archive</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>potx</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.openxmlformats.presentationml.slideshow</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Open XML Slide Show</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.presentation</string>
|
||||
<string>public.zip-archive</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>ppsx</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
</dict>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
sonar.projectKey=storytold_deckcraft
|
||||
sonar.organization=storytold
|
||||
Reference in New Issue
Block a user