mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-08 03:16:25 +00:00
Merge the older community PRs integrated on land-r6 (#204 #243 #291 #345 #258 #303 #230 #168 #177 #692 #522 #466)
# Conflicts: # crates/ui-egui/src/canvas.rs
This commit is contained in:
commit
34f4c1b1df
52 files changed
+4056
-401
No files matched your search
@@ -75,7 +75,7 @@ Today it can:
|
||||
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
|
||||
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
|
||||
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
|
||||
with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover.
|
||||
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
|
||||
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
|
||||
MCP server for AI agents.
|
||||
|
||||
|
||||
+12
@@ -33,6 +33,7 @@
|
||||
> **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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Background picture options; percentages unchanged) · **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)
|
||||
@@ -221,6 +222,8 @@ 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 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 |
|
||||
| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) |
|
||||
| 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 |
|
||||
@@ -240,6 +243,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
|
||||
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
|
||||
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
|
||||
| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
|
||||
| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) |
|
||||
| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge |
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
@@ -248,6 +254,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -298,6 +306,8 @@ 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: Format Background picture and solid options |
|
||||
| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) |
|
||||
| 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 |
|
||||
@@ -308,8 +318,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
|
||||
| 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 |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
pub struct Subcommand {
|
||||
pub name: &'static str,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
}
|
||||
|
||||
pub const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "info",
|
||||
usage: "info FILE",
|
||||
summary: "print a presentation's slides, layouts and theme as JSON",
|
||||
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
|
||||
notes, transition), its layouts, theme fonts, sections and slide size.
|
||||
|
||||
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
|
||||
|
||||
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render-transition",
|
||||
usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
|
||||
summary: "draw frames of the transition into a slide as PNG images",
|
||||
details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
|
||||
as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
|
||||
OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
|
||||
|
||||
--slide N the slide the transition leads into; indices start at 0
|
||||
--t T,T... the times to draw, comma-separated
|
||||
--width PX the image width in pixels, twice the slide width in points by default
|
||||
|
||||
deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
usage: "convert IN OUT",
|
||||
summary: "save a presentation in the format OUT's extension names",
|
||||
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
|
||||
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
|
||||
animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
|
||||
deckcraft-cli convert deck.deckcraft deck.pptx",
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
|
||||
current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
|
||||
slide). --export-params passes file.export options as one JSON object, such as
|
||||
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
|
||||
(`deckcraft-cli describe file.export` lists them).
|
||||
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
--cmd 'format.size={\"size\":28}' \\
|
||||
--save q3.deckcraft
|
||||
|
||||
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
|
||||
parameters.
|
||||
|
||||
What the ids don't say:
|
||||
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
|
||||
[x, y, width, height].
|
||||
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
|
||||
slide.go {\"index\": N} makes slide N current.
|
||||
- Shape ids are looked up on the current slide only.
|
||||
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
|
||||
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
|
||||
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
|
||||
|
||||
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
|
||||
the id of an inserted shape.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "commands",
|
||||
usage: "commands [FILTER]",
|
||||
summary: "list the commands run can use, one per line",
|
||||
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
|
||||
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
|
||||
|
||||
deckcraft-cli commands format.
|
||||
|
||||
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
|
||||
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "describe",
|
||||
usage: "describe COMMAND",
|
||||
summary: "explain one command and its parameters",
|
||||
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
|
||||
calls it.
|
||||
|
||||
deckcraft-cli describe shape.insert",
|
||||
},
|
||||
Subcommand {
|
||||
name: "app",
|
||||
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
|
||||
summary: "run a command in the DeckCraft app started with --control",
|
||||
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
|
||||
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
|
||||
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
|
||||
|
||||
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
|
||||
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample]",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
usage: "bench (FILE | --sample) [--runs N] [--json]",
|
||||
summary: "time opening, drawing, saving and exporting a presentation",
|
||||
details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
|
||||
save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
|
||||
median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
|
||||
|
||||
deckcraft-cli bench deck.pptx --runs 10",
|
||||
},
|
||||
Subcommand {
|
||||
name: "links",
|
||||
usage: "links",
|
||||
summary: "print the DeckCraft community and project links",
|
||||
details: "Prints the DeckCraft community and project links.",
|
||||
},
|
||||
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
|
||||
];
|
||||
|
||||
pub fn page(name: &str) -> Option<String> {
|
||||
let name = match name {
|
||||
"help" | "--help" | "-h" => return Some(overview()),
|
||||
"--version" | "-V" => "version",
|
||||
name => name,
|
||||
};
|
||||
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
|
||||
}
|
||||
|
||||
pub fn overview() -> String {
|
||||
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
|
||||
let list: Vec<String> =
|
||||
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
|
||||
format!(
|
||||
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
|
||||
|
||||
{}
|
||||
|
||||
A presentation is edited by running commands on it:
|
||||
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
+164
-60
@@ -1,22 +1,9 @@
|
||||
//! Headless DeckCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||||
//! N starts at 0. --all writes slide-01.png, slide-02.png, …
|
||||
//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s)
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||||
//! deckcraft-cli describe ID # one command
|
||||
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
|
||||
//! deckcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio
|
||||
//! deckcraft-cli bench [FILE | --sample] [--runs N] [--json] # time open, render, save, export
|
||||
//! deckcraft-cli links | --version
|
||||
//! ```
|
||||
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
@@ -35,23 +22,28 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||||
N starts at 0 (the first slide). --all writes slide-01.png, slide-02.png, …
|
||||
deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png
|
||||
deckcraft-cli convert IN OUT
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
deckcraft-cli commands [FILTER]
|
||||
deckcraft-cli describe COMMAND
|
||||
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
|
||||
deckcraft-cli mcp [--connect PORT] [--sample]
|
||||
deckcraft-cli bench [FILE | --sample] [--runs N] [--json]
|
||||
deckcraft-cli links | --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
|
||||
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
|
||||
outln!("{page}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("help" | "--help" | "-h") => match rest.first() {
|
||||
None => {
|
||||
outln!("{}", help::overview());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some(name) => match help::page(name) {
|
||||
Some(page) => {
|
||||
outln!("{page}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => unknown(name),
|
||||
},
|
||||
},
|
||||
Some("--version" | "-V" | "version") => {
|
||||
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
@@ -62,24 +54,17 @@ fn main() -> ExitCode {
|
||||
Some("render-transition") => report(render_transition(rest)),
|
||||
Some("convert") => report(convert(rest)),
|
||||
Some("run") => report(run(rest)),
|
||||
Some("--help" | "-h" | "help") => {
|
||||
outln!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("commands") if rest.first().is_some_and(|a| is_help(a)) => {
|
||||
outln!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("commands") => {
|
||||
let s = Session::new();
|
||||
let f = rest.first().map(|x| x.to_lowercase());
|
||||
let list: Vec<Value> = s
|
||||
.commands()
|
||||
.into_iter()
|
||||
for c in deckcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||||
.collect();
|
||||
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||||
{
|
||||
match c.params.strip_prefix("{}") {
|
||||
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
|
||||
None => outln!("{} — {}: {}", c.id, c.label, c.params),
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("describe") => report(describe(rest.first().map(String::as_str))),
|
||||
@@ -90,11 +75,8 @@ fn main() -> ExitCode {
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{USAGE}");
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Some(other) => unknown(other),
|
||||
None => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +84,22 @@ fn is_help(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
|
||||
/// The help page of subcommand `name`, else the overview.
|
||||
fn help_page(name: &str) -> String {
|
||||
help::page(name).unwrap_or_else(help::overview)
|
||||
}
|
||||
|
||||
fn unknown(sub: &str) -> ExitCode {
|
||||
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
|
||||
usage()
|
||||
}
|
||||
|
||||
fn usage() -> ExitCode {
|
||||
eprintln!("{}", help::overview());
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
fn report(r: Result<(), String>) -> ExitCode {
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -226,7 +224,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("info"));
|
||||
return Ok(());
|
||||
}
|
||||
let path = args.first().ok_or("info needs a FILE")?;
|
||||
@@ -254,7 +252,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
"--edit" => edit = true,
|
||||
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("render"));
|
||||
return Ok(());
|
||||
}
|
||||
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
|
||||
@@ -403,7 +401,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("convert"));
|
||||
return Ok(());
|
||||
}
|
||||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||||
@@ -412,6 +410,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
open(&mut s, Some(input), false)?;
|
||||
let r = if matches!(fmt, "png" | "jpeg") {
|
||||
s.execute("file.export", &json!({"path": out, "slide": 0}))
|
||||
} else if fmt == "gif" {
|
||||
s.execute("file.export", &json!({"path": out}))
|
||||
} else {
|
||||
s.execute("file.saveAs", &json!({"path": out}))
|
||||
};
|
||||
@@ -436,18 +436,26 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut cmds = vec![];
|
||||
let mut save = None;
|
||||
let mut export = None;
|
||||
let mut export_params = json!({});
|
||||
let mut print = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
let j = it.next().ok_or("--export-params needs a JSON object")?;
|
||||
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
|
||||
if !export_params.is_object() {
|
||||
return Err("--export-params must be a JSON object".into());
|
||||
}
|
||||
}
|
||||
"--print" => print = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("run"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
@@ -472,22 +480,76 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = export {
|
||||
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
export_params["path"] = json!(p);
|
||||
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
|
||||
} else if export_params != json!({}) {
|
||||
return Err("--export-params needs --export OUT".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||||
if id.is_some_and(is_help) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("describe"));
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or("describe needs a command id")?;
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
|
||||
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
|
||||
let mut head = format!("{}: {}", c.id, c.label);
|
||||
if !c.menu.is_empty() {
|
||||
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
|
||||
}
|
||||
if let Some(k) = c.shortcut {
|
||||
head.push_str(&format!(", shortcut {k}"));
|
||||
}
|
||||
outln!("{head}.\n");
|
||||
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
|
||||
let parsed = deckcraft_engine::cmd::parse_params(c.params);
|
||||
match &parsed {
|
||||
Err(_) => outln!("Parameters: {}", args.trim()),
|
||||
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
|
||||
Ok(params) => print_params(params),
|
||||
}
|
||||
let params = parsed.unwrap_or_default();
|
||||
if let Some(acts) = c.acts_on() {
|
||||
outln!("{acts}");
|
||||
}
|
||||
let remark = remark(args);
|
||||
if !remark.is_empty() {
|
||||
outln!("Note: {remark}");
|
||||
}
|
||||
if !answer.trim().is_empty() {
|
||||
outln!("Answers with {}.", answer.trim());
|
||||
}
|
||||
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
|
||||
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
|
||||
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
|
||||
let name = |p: &deckcraft_engine::cmd::Param| {
|
||||
let or = if p.or { "or " } else { "" };
|
||||
let optional = if p.optional { ", optional" } else { "" };
|
||||
format!("{or}{}{optional}", p.name)
|
||||
};
|
||||
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
|
||||
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
|
||||
outln!("Parameters:");
|
||||
for (k, p) in params.iter().enumerate() {
|
||||
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
|
||||
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
|
||||
}
|
||||
let indent = if sets { " " } else { " " };
|
||||
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
fn remark(args: &str) -> &str {
|
||||
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
|
||||
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_mcp::{Backend, Remote, control_addr};
|
||||
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
|
||||
@@ -499,7 +561,7 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
|
||||
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("app"));
|
||||
return Ok(());
|
||||
}
|
||||
other => rest.push(other.to_string()),
|
||||
@@ -531,7 +593,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("mcp"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
@@ -554,6 +616,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
@@ -582,6 +646,46 @@ mod tests {
|
||||
assert!(describe(Some("--help")).is_ok());
|
||||
assert!(mcp(&args(&["-h"])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
|
||||
let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
for flag in ["--bogus", "-x"] {
|
||||
let args: Vec<String> = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
|
||||
let e = render(&args).unwrap_err();
|
||||
assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
|
||||
assert!(!out.exists(), "{flag}: rendered anyway");
|
||||
}
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gif = dir.join("sample.gif").to_string_lossy().to_string();
|
||||
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
|
||||
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
|
||||
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
|
||||
let bytes = std::fs::read(&gif).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
|
||||
|
||||
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
|
||||
convert(&args(&[&deck, &gif2])).unwrap();
|
||||
let bytes = std::fs::read(&gif2).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
|
||||
|
||||
// Bad options are errors, not panics.
|
||||
let bad = dir.join("bad.gif").to_string_lossy().to_string();
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
|
||||
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(o: &Output) -> String {
|
||||
assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr));
|
||||
String::from_utf8_lossy(&o.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(o: &Output) -> String {
|
||||
assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout));
|
||||
String::from_utf8_lossy(&o.stderr).into_owned()
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"];
|
||||
|
||||
fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
let err = stderr(&cli(&args));
|
||||
assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_subcommand_shows_the_overview_and_fails() {
|
||||
assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_subcommand_with_a_run_example() {
|
||||
for flag in ["help", "--help", "-h"] {
|
||||
let out = stdout(&cli(&[flag]));
|
||||
for sub in SUBCOMMANDS {
|
||||
assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}");
|
||||
}
|
||||
assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subcommand_answers_help_and_does_nothing_else() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for sub in SUBCOMMANDS {
|
||||
for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
|
||||
assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for args in [["help", "version"], ["--version", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}");
|
||||
}
|
||||
for args in [["help", "help"], ["help", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_help_says_what_the_ids_do_not() {
|
||||
let out = words(&stdout(&cli(&["run", "--help"])));
|
||||
for fact in [
|
||||
"COMMAND is a command id, then `=`, then the command's parameters as one JSON object.",
|
||||
"points on a 960×540 slide",
|
||||
"Slide indices start at 0.",
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_spells_out_a_command() {
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.insert"])),
|
||||
"shape.insert: Shapes, in Insert ▸ Illustrations.
|
||||
|
||||
Parameters:
|
||||
preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets)
|
||||
rect [x,y,w,h] pt
|
||||
text, optional string
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'shape.insert={…}'
|
||||
"
|
||||
);
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "format.size"])),
|
||||
"format.size: Font Size, in Home ▸ Font.
|
||||
|
||||
Parameters:
|
||||
size pt
|
||||
Acts on the selected text or shapes.
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'format.size={…}'
|
||||
"
|
||||
);
|
||||
assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
|
||||
assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.setDefault"])),
|
||||
"shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_shows_alternative_sets_of_parameters() {
|
||||
assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n"));
|
||||
let remove = stdout(&cli(&["describe", "animation.remove"]));
|
||||
assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}");
|
||||
assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}");
|
||||
assert!(stdout(&cli(&["describe", "shape.move"])).contains(
|
||||
" either:
|
||||
dx, optional pt (relative)
|
||||
dy, optional pt (relative)
|
||||
ids, optional
|
||||
or:
|
||||
x, optional pt (absolute)
|
||||
y, optional pt (absolute)
|
||||
ids, optional
|
||||
"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_lists_one_line_per_command() {
|
||||
assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n");
|
||||
}
|
||||
@@ -21,7 +21,7 @@ fn convert_rejects_output_formats_it_cannot_write() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
for name in ["out.odp", "out.gif", "out.svg", "out.outline", "out"] {
|
||||
for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] {
|
||||
let target = dir.join(name);
|
||||
let out = cli(&[Path::new("convert"), &input, &target]);
|
||||
assert!(!out.status.success(), "{name}: convert should fail");
|
||||
|
||||
@@ -181,6 +181,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("gif") => "image/gif",
|
||||
Some("txt") => "text/plain",
|
||||
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
Some("deckcraft") => "application/zip",
|
||||
|
||||
@@ -48,3 +48,6 @@ sync_github_name = true
|
||||
|
||||
[people.Momentum96]
|
||||
sync_github_name = true
|
||||
|
||||
[people.RCShoemaker]
|
||||
real_name = "Ryan C. Shoemaker"
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove),
|
||||
cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim),
|
||||
cmd!(
|
||||
"animation.timing",
|
||||
|
||||
+303
-34
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format Background",
|
||||
["Design", "Customize"],
|
||||
None,
|
||||
"{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}",
|
||||
"{color? | gradient? | picture?: base64 | pattern? | style?: 1..12 | reset?, all?: bool, index?, transparency?: 0..1, pictureProps?: {tile?, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0.01..10, align?, mirror?, stretch?: [l,t,r,b], rotateWithShape?}, copy?: bool}",
|
||||
has_slide,
|
||||
background
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Slide Size",
|
||||
["Design", "Customize"],
|
||||
None,
|
||||
"{preset?: widescreen|standard|… | w, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
|
||||
"{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
|
||||
has_doc,
|
||||
slide_size
|
||||
),
|
||||
@@ -480,70 +480,256 @@ fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bg: Option<Background> = if bool_or(p, "reset", false) {
|
||||
None
|
||||
} else if let Some(c) = color_param(p, "color") {
|
||||
Some(Background::Fill { fill: Fill::solid(c) })
|
||||
} else if let Some(i) = usize_param(p, "style") {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let color = color_param(p, "color");
|
||||
let style = usize_param(p, "style");
|
||||
let op = if reset {
|
||||
BgOp::Set(None)
|
||||
} else if let Some(mut c) = color {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(&mut c, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
|
||||
} else if let Some(i) = style {
|
||||
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
|
||||
let slots =
|
||||
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
|
||||
let i = i.clamp(1, 12) - 1;
|
||||
Some(Background::Ref {
|
||||
BgOp::Set(Some(Background::Ref {
|
||||
idx: 1001 + (i / 4) as u32,
|
||||
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
|
||||
})
|
||||
}))
|
||||
} else if let Some(g) = p.get("gradient") {
|
||||
let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
|
||||
g.rotate_with_shape = false;
|
||||
Some(Background::Fill { fill: Fill::Gradient(g) })
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
|
||||
} else if let Some(pt) = p.get("pattern") {
|
||||
Some(Background::Fill {
|
||||
BgOp::Set(Some(Background::Fill {
|
||||
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
|
||||
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
|
||||
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
|
||||
}),
|
||||
})
|
||||
}))
|
||||
} else if p.get("picture").is_some() {
|
||||
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
|
||||
let ct = super::insert::content_type(&name, &bytes);
|
||||
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
|
||||
Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) })
|
||||
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
if let Some(pp) = &props {
|
||||
apply_picture_props(&mut pf, pp);
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(&mut pf, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
|
||||
} else if props.is_some() || transparency.is_some() {
|
||||
BgOp::Adjust
|
||||
} else {
|
||||
return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`"));
|
||||
return Err(bad(
|
||||
"design.background",
|
||||
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
let index = usize_param(p, "index");
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
let current = background_of(&st.doc, &st.selection, index);
|
||||
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
|
||||
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
|
||||
if props.is_some() && !picture {
|
||||
return Err(bad("design.background", "picture options need a picture background"));
|
||||
}
|
||||
if transparency.is_some() && !picture && !solid {
|
||||
return Err(bad("design.background", "transparency needs a solid or picture background"));
|
||||
}
|
||||
let mut next = current.clone();
|
||||
if let Some(bg) = next.as_mut() {
|
||||
adjust_background(bg, props.as_ref(), transparency);
|
||||
}
|
||||
if next == current { None } else { Some(next) }
|
||||
};
|
||||
let Some(next) = next else { return ok() };
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, index, false, next);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let BgOp::Set(bg) = op else { return ok() };
|
||||
let unchanged = {
|
||||
let st = s.doc()?;
|
||||
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
|
||||
};
|
||||
if unchanged {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
match sel.target {
|
||||
Target::Master { master } => {
|
||||
if let Some(m) = doc.masters.get_mut(master) {
|
||||
Arc::make_mut(m).background = bg.clone();
|
||||
write_background(doc, sel, index, all, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
enum BgOp {
|
||||
Set(Option<Background>),
|
||||
Adjust,
|
||||
}
|
||||
|
||||
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
|
||||
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
!list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
|
||||
match sel.target {
|
||||
Target::Master { master } => {
|
||||
if let Some(m) = doc.masters.get_mut(master) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
|
||||
l.background = bg;
|
||||
}
|
||||
}
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
if let Some(m) = crate::page_master_mut(doc, t) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).background = bg.clone();
|
||||
}
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
|
||||
l.background = bg.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_slide_background(s: &mut Session) -> Result<Value> {
|
||||
let bg = {
|
||||
let st = s.doc()?;
|
||||
if !matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("design.background", "copy applies to slides"));
|
||||
}
|
||||
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
|
||||
if st.doc.slides.iter().all(|sl| sl.background == bg) {
|
||||
return ok();
|
||||
}
|
||||
bg
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, None, true, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
|
||||
match bg {
|
||||
Background::Fill { fill: Fill::Picture(pf) } => {
|
||||
if let Some(pp) = props {
|
||||
apply_picture_props(pf, pp);
|
||||
}
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
if let Some(m) = crate::page_master_mut(doc, t) {
|
||||
Arc::make_mut(m).background = bg.clone();
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(pf, t);
|
||||
}
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).background = bg.clone();
|
||||
}
|
||||
Background::Fill { fill: Fill::Solid { color } } => {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(color, t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
|
||||
let a = 1.0 - t.clamp(0.0, 1.0);
|
||||
pf.alpha = (a < 1.0).then_some(a);
|
||||
}
|
||||
|
||||
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
|
||||
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t > 0.0 {
|
||||
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
|
||||
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||||
pf.upright = !up;
|
||||
}
|
||||
match pp.get("tile").and_then(Value::as_bool) {
|
||||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
}
|
||||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||||
_ => {}
|
||||
}
|
||||
match &mut pf.mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
if let Some(v) = f("offsetX") {
|
||||
*tx = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("offsetY") {
|
||||
*ty = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("scaleX") {
|
||||
*sx = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(v) = f("scaleY") {
|
||||
*sy = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||||
*flip = m.to_string();
|
||||
}
|
||||
if let Some(a) =
|
||||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||||
{
|
||||
*align = a.to_string();
|
||||
}
|
||||
}
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||||
*slot = v.clamp(-10.0, 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1266,7 +1452,9 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
|
||||
use deckcraft_model::{Background, Fill};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, UiRequest};
|
||||
@@ -1283,6 +1471,21 @@ mod tests {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn png() -> String {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
crate::cmd::base64_encode(&bytes)
|
||||
}
|
||||
|
||||
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
|
||||
other => panic!("expected a picture background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||||
@@ -1453,6 +1656,72 @@ mod tests {
|
||||
assert_eq!(font_scheme(&s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_picture_options_tile_transparency_and_copy() {
|
||||
let mut s = Session::with_new();
|
||||
let n = undo_len(&s);
|
||||
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
|
||||
assert!(err.contains("picture background"), "{err}");
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
|
||||
other => panic!("expected a solid background, got {other:?}"),
|
||||
}
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
|
||||
let pf = picture(&s);
|
||||
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
|
||||
let n = undo_len(&s);
|
||||
|
||||
s.execute(
|
||||
"design.background",
|
||||
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
|
||||
)
|
||||
.unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
|
||||
assert_eq!(flip, "xy");
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected a tile, got {other:?}"),
|
||||
}
|
||||
assert!(picture(&s).upright);
|
||||
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, align, .. } => {
|
||||
assert_eq!(tx, 12.0);
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected the tile to stay, got {other:?}"),
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
|
||||
other => panic!("expected a stretch, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3, orientation?: portrait|landscape, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
|
||||
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
|
||||
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard),
|
||||
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
|
||||
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
|
||||
]
|
||||
@@ -208,12 +208,13 @@ fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`).
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`).
|
||||
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -287,16 +288,21 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "txt", "md"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.gif`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// those as native `.deckcraft` bytes under the wrong name.
|
||||
pub fn writable_format_for_path(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path))
|
||||
}
|
||||
|
||||
/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]).
|
||||
pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes)
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
let l = path.to_ascii_lowercase();
|
||||
if l.ends_with(".pptx") {
|
||||
@@ -311,6 +317,8 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"jpeg"
|
||||
} else if l.ends_with(".pdf") {
|
||||
"pdf"
|
||||
} else if l.ends_with(".gif") {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else {
|
||||
@@ -334,10 +342,30 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
|
||||
let tmp = format!("{path}.saving");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
|
||||
// Write to a uniquely named temporary sibling, created exclusively (never following or reusing an
|
||||
// existing file or symlink), then rename it over the destination so a failed save never truncates
|
||||
// the old file and concurrent saves never share temporary bytes.
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
let written = file.write_all(bytes).and_then(|()| file.sync_all());
|
||||
drop(file);
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
@@ -470,6 +498,23 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
"gif" => {
|
||||
// `slides` may be a range, as for PDF ("1-3,5").
|
||||
let mut gp = p.clone();
|
||||
if let Some(text) = p.get("slides").and_then(Value::as_str) {
|
||||
gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
|
||||
}
|
||||
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?;
|
||||
write_file(&path, &out.bytes)?;
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"bytes": out.bytes.len(),
|
||||
"frames": out.frames,
|
||||
"width": out.width,
|
||||
"height": out.height,
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
write_file(&path, &bytes)?;
|
||||
@@ -595,3 +640,43 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "color_at_tests.rs"]
|
||||
mod color_at_tests;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::write_file;
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_does_not_touch_a_predictable_saving_sibling() {
|
||||
let dir = scratch("sibling");
|
||||
let dest = dir.join("deck.deckcraft");
|
||||
let dest_str = dest.to_str().unwrap();
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap();
|
||||
write_file(dest_str, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
|
||||
assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious");
|
||||
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_failed_rename_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch("failed");
|
||||
let dest = dir.join("target");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
assert!(write_file(dest.to_str().unwrap(), b"new").is_err());
|
||||
assert!(dest.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -41,15 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size),
|
||||
cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow),
|
||||
cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink),
|
||||
cmd!(
|
||||
"format.color",
|
||||
"Font Color",
|
||||
["Home", "Font"],
|
||||
None,
|
||||
"{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}",
|
||||
has_text_or_shapes,
|
||||
color
|
||||
),
|
||||
cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color),
|
||||
cmd!(
|
||||
"format.textTransparency",
|
||||
"Text Transparency",
|
||||
@@ -214,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Box Margins",
|
||||
["Format Shape", "Text Box"],
|
||||
None,
|
||||
"{left?, top?, right?, bottom?: pt}",
|
||||
"{left?: pt, top?: pt, right?: pt, bottom?: pt}",
|
||||
has_text_or_shapes,
|
||||
margins
|
||||
),
|
||||
@@ -1206,6 +1198,7 @@ fn state(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
"align": pp.align.map(|a| a.xml()),
|
||||
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
|
||||
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
|
||||
"lang": r.lang,
|
||||
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
|
||||
"painter": s.painter.is_some(),
|
||||
}))
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
|
||||
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
|
||||
"notes": sl.notes_text(),
|
||||
"notesBody": sl.notes,
|
||||
"comments": sl.comments.len(),
|
||||
"hidden": sl.hidden,
|
||||
}))
|
||||
|
||||
@@ -43,6 +43,9 @@ pub struct CommandSpec {
|
||||
/// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu).
|
||||
pub menu: &'static [&'static str],
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks
|
||||
/// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values
|
||||
/// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -63,6 +66,28 @@ pub struct CommandInfo {
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
/// Holds because `Session::execute` skips the selection check when `id` or `ids` is given.
|
||||
pub fn acts_on(&self) -> Option<String> {
|
||||
let keys = param_keys(self.params).unwrap_or_default();
|
||||
let named = if keys.contains(&"ids") {
|
||||
Some("the shapes in `ids`")
|
||||
} else if keys.contains(&"id") {
|
||||
Some("the shape in `id`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reason = (self.enabled)(&Session::with_new()).err()?;
|
||||
Some(match (reason.as_str(), named) {
|
||||
(SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(),
|
||||
(NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."),
|
||||
(NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(),
|
||||
(SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."),
|
||||
(SELECT_TWO, None) => "Acts on two or more selected shapes.".into(),
|
||||
(NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
@@ -89,6 +114,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub const NOTHING_SELECTED: &str = "nothing selected";
|
||||
pub const SELECT_TWO: &str = "select two or more objects";
|
||||
pub const NO_TEXT_POINT: &str = "no text insertion point";
|
||||
pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape";
|
||||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
|
||||
@@ -99,17 +126,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) }
|
||||
}
|
||||
/// Text editing or shapes with text selected (formatting commands apply to either).
|
||||
/// Text editing or selected shapes (formatting commands apply to either).
|
||||
pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select text or a shape".into())
|
||||
}
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) }
|
||||
}
|
||||
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
@@ -167,6 +191,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn param_keys(doc: &str) -> std::result::Result<Vec<&str>, String> {
|
||||
let mut keys = vec![];
|
||||
for p in parse_params(doc)? {
|
||||
if !keys.contains(&p.name) {
|
||||
keys.push(p.name);
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Param<'a> {
|
||||
pub name: &'a str,
|
||||
pub optional: bool,
|
||||
/// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`).
|
||||
pub about: &'a str,
|
||||
/// An alternative to the parameter before it in the same object (`color? | none?`).
|
||||
pub or: bool,
|
||||
/// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1.
|
||||
pub object: usize,
|
||||
}
|
||||
|
||||
/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset,
|
||||
/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result
|
||||
/// and is skipped. Errs where the doc strays from that grammar.
|
||||
pub fn parse_params(doc: &str) -> std::result::Result<Vec<Param<'_>>, String> {
|
||||
let doc = doc.split('→').next().unwrap_or_default();
|
||||
let mut params: Vec<Param> = vec![];
|
||||
let mut open: Vec<char> = vec![];
|
||||
let mut expect_key = false;
|
||||
let mut or = false;
|
||||
let mut objects: usize = 0;
|
||||
let mut about_from: Option<usize> = None;
|
||||
let mut chars = doc.char_indices().peekable();
|
||||
let mut prev = ' ';
|
||||
fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option<usize>, to: usize) {
|
||||
if let (Some(f), Some(p)) = (from.take(), params.last_mut()) {
|
||||
p.about = doc.get(f..to).unwrap_or_default().trim();
|
||||
}
|
||||
}
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let in_object = open == ['{'];
|
||||
match c {
|
||||
'{' | '[' | '(' => {
|
||||
open.push(c);
|
||||
if open == ['{'] {
|
||||
expect_key = true;
|
||||
objects += 1;
|
||||
or = false;
|
||||
}
|
||||
}
|
||||
'}' | ']' | ')' => {
|
||||
if in_object {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
}
|
||||
open.pop();
|
||||
}
|
||||
',' if in_object => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
}
|
||||
'|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
or = true;
|
||||
}
|
||||
c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => {
|
||||
let mut end = i + c.len_utf8();
|
||||
while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') {
|
||||
end = j + n.len_utf8();
|
||||
chars.next();
|
||||
}
|
||||
let after = doc.get(end..).unwrap_or_default();
|
||||
let rest = after.trim_start_matches('?').trim_start();
|
||||
let at = doc.len() - rest.len();
|
||||
about_from = match rest.chars().next() {
|
||||
Some(':') => Some(at + 1),
|
||||
Some('(') => Some(at),
|
||||
Some(',' | '}' | '|') => None,
|
||||
_ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::<String>())),
|
||||
};
|
||||
params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) });
|
||||
or = false;
|
||||
expect_key = false;
|
||||
}
|
||||
c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])),
|
||||
_ => {}
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
|
||||
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
|
||||
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
|
||||
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
|
||||
cmd!(
|
||||
"review.replaceWord",
|
||||
"Insert Synonym",
|
||||
|
||||
@@ -39,8 +39,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.move",
|
||||
"Move",
|
||||
[],
|
||||
None,
|
||||
"{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}",
|
||||
has_selection,
|
||||
move_by
|
||||
),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, 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",
|
||||
@@ -121,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
"picture.crop",
|
||||
"Crop",
|
||||
["Picture Format", "Size"],
|
||||
None,
|
||||
"{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
|
||||
has_selection,
|
||||
crop
|
||||
),
|
||||
cmd!(
|
||||
"picture.adjust",
|
||||
"Corrections",
|
||||
|
||||
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
|
||||
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
|
||||
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
|
||||
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
|
||||
cmd!(
|
||||
"slide.notes",
|
||||
"Notes",
|
||||
["View"],
|
||||
None,
|
||||
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
|
||||
has_slide,
|
||||
notes
|
||||
),
|
||||
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
|
||||
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
|
||||
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
|
||||
@@ -468,10 +476,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = index_of(s, p)?;
|
||||
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
|
||||
// changes, so bold, levels and bullets around it survive.
|
||||
let body = match p.get("body") {
|
||||
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).notes = TextBody::from_text(&text);
|
||||
let notes = &mut Arc::make_mut(sl).notes;
|
||||
match body {
|
||||
Some(b) => *notes = b,
|
||||
None => deckcraft_model::edit::replace_text(notes, &text),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Table Design and Table Layout tabs.
|
||||
|
||||
use deckcraft_model::{ShapeKind, Table};
|
||||
use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::{Shape, ShapeKind, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
use crate::{DocState, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -27,7 +28,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s, p, "delCol"
|
||||
)),
|
||||
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
|
||||
cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split),
|
||||
cmd!(
|
||||
"table.split",
|
||||
"Split Cells",
|
||||
["Table Layout", "Merge"],
|
||||
None,
|
||||
"{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}",
|
||||
has_selection,
|
||||
split
|
||||
),
|
||||
cmd!(
|
||||
"table.style",
|
||||
"Table Styles",
|
||||
@@ -42,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Table Style Options",
|
||||
["Table Design", "Table Style Options"],
|
||||
None,
|
||||
"{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}",
|
||||
"{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}",
|
||||
has_selection,
|
||||
options
|
||||
),
|
||||
@@ -86,7 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(
|
||||
s, p, false
|
||||
)),
|
||||
cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width),
|
||||
cmd!(
|
||||
"table.columnWidth",
|
||||
"Width",
|
||||
["Table Layout", "Cell Size"],
|
||||
None,
|
||||
"{col, width: pt, keepTotal?: bool (the next column absorbs the change), id?}",
|
||||
has_selection,
|
||||
column_width
|
||||
),
|
||||
cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height),
|
||||
cmd!(
|
||||
"table.drawLine",
|
||||
@@ -110,6 +127,104 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Narrowest a column gets when its border is dragged (points).
|
||||
pub const MIN_COLUMN_WIDTH: f64 = 12.0;
|
||||
|
||||
/// A table as drawn on the slide: where it is, its drawn row heights and its visible cells.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TableFrame {
|
||||
/// Table-local (points from the table's top-left corner) → slide space, as the table is drawn.
|
||||
pub affine: Affine,
|
||||
pub cols: Vec<f64>,
|
||||
/// Row heights as drawn: a stored height is a minimum, rows grow to fit their text.
|
||||
pub heights: Vec<f64>,
|
||||
/// Visible cells (merges honoured): (row, col, table-local rect).
|
||||
pub cells: Vec<(usize, usize, Rect)>,
|
||||
}
|
||||
|
||||
/// A border between table rows or columns that can be dragged.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TableBorder {
|
||||
/// The right edge of this column (the last one is the table's right edge).
|
||||
Column(usize),
|
||||
/// The bottom edge of this row (the last one is the table's bottom edge).
|
||||
Row(usize),
|
||||
}
|
||||
|
||||
/// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table).
|
||||
pub fn table_frame(st: &DocState, sh: &Shape) -> Option<TableFrame> {
|
||||
let ShapeKind::Table(t) = &sh.kind else { return None };
|
||||
// The table is drawn in its own box; rows that grow past it don't move it.
|
||||
let x = sh.xfrm.unwrap_or_else(|| xfrm_of(&st.doc, &st.selection, sh));
|
||||
let heights = table_drawn_heights(&st.doc, &st.selection, t);
|
||||
let cells = deckcraft_render::table_cell_rects(t, &heights);
|
||||
Some(TableFrame { affine: x.affine(), cols: t.cols.clone(), heights, cells })
|
||||
}
|
||||
|
||||
impl TableFrame {
|
||||
pub fn width(&self) -> f64 {
|
||||
self.cols.iter().map(|w| w.max(0.0)).sum()
|
||||
}
|
||||
|
||||
pub fn height(&self) -> f64 {
|
||||
self.heights.iter().map(|h| h.max(0.0)).sum()
|
||||
}
|
||||
|
||||
/// Table-local point for slide point `p`.
|
||||
pub fn local(&self, p: Point) -> Point {
|
||||
self.affine.inverse() * p
|
||||
}
|
||||
|
||||
/// Table-local rect of visible cell (`r`, `c`).
|
||||
pub fn cell_rect(&self, r: usize, c: usize) -> Option<Rect> {
|
||||
self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2)
|
||||
}
|
||||
|
||||
/// The visible cell at table-local point `p` (points outside the table go to the nearest cell).
|
||||
pub fn cell_at(&self, p: Point) -> Option<(usize, usize)> {
|
||||
let q = Point::new(p.x.clamp(0.0, self.width().max(0.0)), p.y.clamp(0.0, self.height().max(0.0)));
|
||||
let inside = |r: &Rect| q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1;
|
||||
// Edges are shared: prefer the cell the point is strictly inside.
|
||||
self.cells
|
||||
.iter()
|
||||
.find(|(_, _, r)| q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1)
|
||||
.or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r)))
|
||||
.map(|(r, c, _)| (*r, *c))
|
||||
}
|
||||
|
||||
/// The draggable border within `tol` of table-local point `p`: the inner borders and the right
|
||||
/// and bottom edges (the left and top edges move the table). Borders hidden inside merged
|
||||
/// cells don't count.
|
||||
pub fn border_at(&self, p: Point, tol: f64) -> Option<TableBorder> {
|
||||
let (w, h) = (self.width(), self.height());
|
||||
if !(p.x >= -tol && p.y >= -tol && p.x <= w + tol && p.y <= h + tol) {
|
||||
return None;
|
||||
}
|
||||
let q = Point::new(p.x.clamp(0.0, w), p.y.clamp(0.0, h));
|
||||
let mut best: Option<(f64, TableBorder)> = None;
|
||||
let mut x = 0.0;
|
||||
for (i, cw) in self.cols.iter().enumerate() {
|
||||
x += cw.max(0.0);
|
||||
let d = (p.x - x).abs();
|
||||
// A cell ending at this edge at this height shows the border.
|
||||
let shown = self.cells.iter().any(|(_, _, r)| (r.x1 - x).abs() < 1e-6 && q.y >= r.y0 && q.y <= r.y1);
|
||||
if d <= tol && shown && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, TableBorder::Column(i)));
|
||||
}
|
||||
}
|
||||
let mut y = 0.0;
|
||||
for (i, rh) in self.heights.iter().enumerate() {
|
||||
y += rh.max(0.0);
|
||||
let d = (p.y - y).abs();
|
||||
let shown = self.cells.iter().any(|(_, _, r)| (r.y1 - y).abs() < 1e-6 && q.x >= r.x0 && q.x <= r.x1);
|
||||
if d <= tol && shown && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, TableBorder::Row(i)));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -418,9 +533,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
|
||||
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (_, c) = at(s, p);
|
||||
let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0);
|
||||
let keep_total = bool_or(p, "keepTotal", false);
|
||||
with_table(s, p, "table.columnWidth", move |t| {
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
// Keeping the table's width, the column to the right gives or takes the difference (dragging
|
||||
// an inner border). The last column has none: the table gets wider or narrower.
|
||||
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
|
||||
match pair {
|
||||
Some(pair) if keep_total => {
|
||||
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
|
||||
let w = w.max(min).min(pair - min);
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
}
|
||||
if let Some(x) = t.cols.get_mut(c + 1) {
|
||||
*x = pair - w;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::edit::{self as ed, Pos};
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::{Presentation, ShapeId, ShapeKind};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -114,7 +115,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
|
||||
|
||||
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
|
||||
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?;
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
|
||||
// Materialise an inherited placeholder box before editing (so autofit can change it).
|
||||
let r = s.edit(|doc, sel| {
|
||||
let mut t = t0.clone();
|
||||
@@ -183,20 +184,44 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<
|
||||
|
||||
/// Lay out the text being edited (for vertical caret movement and hit testing).
|
||||
pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayout> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
text_frame(st, t).map(|f| f.0)
|
||||
}
|
||||
|
||||
/// The text being edited as it is drawn: its layout, and the transform from layout space to the
|
||||
/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the
|
||||
/// caret, the text selection, hit testing and caret movement all use it. `None` for notes.
|
||||
pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> {
|
||||
if t.notes {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let body = sh.text.as_ref()?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let lay = |s: &Shape, body: &TextBody, rect: Rect| {
|
||||
deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false })
|
||||
};
|
||||
if let Some((r, c)) = t.cell {
|
||||
// A cell's text is laid out in the cell's rect with the cell's margins, anchor and the
|
||||
// table style's text look, exactly as the table draws it.
|
||||
let placed = cell_on_slide(st, t.shape, r, c)?;
|
||||
return Some((placed.layout, placed.to_slide));
|
||||
}
|
||||
let body = sh.text.as_ref()?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
Some(deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
))
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let l = lay(sh, body, tr);
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
Some((l, m))
|
||||
}
|
||||
|
||||
/// The text position under slide point `p` in the text being edited (its shape or cell).
|
||||
pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos {
|
||||
match text_frame(st, t) {
|
||||
Some((l, m)) => {
|
||||
let h = l.hit(m.inverse() * p);
|
||||
(h.para, h.ch)
|
||||
}
|
||||
None => (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
@@ -1104,7 +1129,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", "select text or a shape"));
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
match &mut sh.kind {
|
||||
@@ -1194,7 +1219,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", "select text or a shape"));
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
match &mut sh.kind {
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sound",
|
||||
["Transitions", "Timing"],
|
||||
None,
|
||||
"{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?; none?: true (No Sound); stopPrevious?: true; loop?: bool (Loop Until Next Sound); index?} — the sound plays as the slide comes in",
|
||||
"{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?, none?: true (No Sound), stopPrevious?: true, loop?: bool (Loop Until Next Sound), index?} — the sound plays as the slide comes in",
|
||||
has_slide,
|
||||
sound
|
||||
),
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Animated GIF of the deck (the Export… dialog; `file.export` with format `gif`).
|
||||
//!
|
||||
//! One frame per slide, drawn by the slide renderer and shown for `secondsPerSlide`. Every frame
|
||||
//! gets its own 256-colour palette (exact when the slide has 256 colours or fewer, NeuQuant
|
||||
//! otherwise), so a photo on one slide doesn't steal colours from the next. Transitions and
|
||||
//! build animations are not animated: a slide appears as it looks when its builds have finished.
|
||||
//!
|
||||
//! All parameters are hostile: sizes, delays and slide lists are checked against fixed bounds, and
|
||||
//! the total number of pixels encoded is capped, so a request can't exhaust memory or run for
|
||||
//! hours.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::bad;
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const CMD: &str = "file.export";
|
||||
|
||||
/// Size presets, by frame width in pixels (16:9 slides: 640×360, 960×540, 1280×720, 1920×1080).
|
||||
pub const SIZE_PRESETS: [(&str, u32); 4] = [("small", 640), ("medium", 960), ("large", 1280), ("extraLarge", 1920)];
|
||||
/// Frame width when none is given (the `medium` preset).
|
||||
pub const DEFAULT_WIDTH: u32 = 960;
|
||||
pub const MIN_WIDTH: u32 = 16;
|
||||
pub const MAX_WIDTH: u32 = 3840;
|
||||
/// Longest frame side: a very tall slide is scaled down to fit.
|
||||
pub const MAX_SIDE: u32 = 3840;
|
||||
pub const DEFAULT_SECONDS: f64 = 5.0;
|
||||
/// GIF delays are in hundredths of a second; viewers treat delays under 0.02 s as 0.1 s anyway.
|
||||
pub const MIN_SECONDS: f64 = 0.1;
|
||||
pub const MAX_SECONDS: f64 = 600.0;
|
||||
pub const MAX_FRAMES: usize = 500;
|
||||
/// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540).
|
||||
pub const MAX_TOTAL_PIXELS: u64 = 200_000_000;
|
||||
/// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance.
|
||||
const QUANTIZER_SPEED: i32 = 10;
|
||||
|
||||
/// What to export.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GifOptions {
|
||||
/// Frame width in pixels; the height follows the slide's aspect ratio.
|
||||
pub width: u32,
|
||||
pub seconds_per_slide: f64,
|
||||
/// Loop forever (`true`) or play once.
|
||||
pub loop_forever: bool,
|
||||
/// Slide indices in play order (default: all).
|
||||
pub slides: Option<Vec<usize>>,
|
||||
/// Hidden slides are left out unless this is set.
|
||||
pub include_hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for GifOptions {
|
||||
fn default() -> Self {
|
||||
GifOptions { width: DEFAULT_WIDTH, seconds_per_slide: DEFAULT_SECONDS, loop_forever: true, slides: None, include_hidden: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// An encoded GIF and what is in it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GifOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
pub frames: usize,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Per-frame delay in hundredths of a second, as written.
|
||||
pub delay_cs: u16,
|
||||
}
|
||||
|
||||
fn width_value(v: &Value) -> Result<u32> {
|
||||
let err =
|
||||
|| bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}, or one of small, medium, large, extraLarge"));
|
||||
match v {
|
||||
Value::String(s) => SIZE_PRESETS.iter().find(|(k, _)| k.eq_ignore_ascii_case(s.trim())).map(|(_, w)| *w).ok_or_else(err),
|
||||
Value::Number(n) => n.as_u64().and_then(|w| u32::try_from(w).ok()).filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w)).ok_or_else(err),
|
||||
_ => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a per-slide duration (NaN, infinities and out-of-range values are errors).
|
||||
pub fn check_seconds(s: f64) -> Result<f64> {
|
||||
if (MIN_SECONDS..=MAX_SECONDS).contains(&s) {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(bad(CMD, format!("`secondsPerSlide` must be from {MIN_SECONDS} to {MAX_SECONDS}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Present and not `null`.
|
||||
fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
p.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
impl GifOptions {
|
||||
/// Read `{width?: px | small|medium|large|extraLarge, secondsPerSlide?, loop?: bool |
|
||||
/// forever|once, slides?: [index], includeHidden?}`. Absent or `null` values take their
|
||||
/// defaults; anything else that is out of range or of the wrong type is an error.
|
||||
pub fn from_params(p: &Value) -> Result<Self> {
|
||||
let mut o = GifOptions::default();
|
||||
if let Some(v) = given(p, "width") {
|
||||
o.width = width_value(v)?;
|
||||
}
|
||||
if let Some(v) = given(p, "secondsPerSlide") {
|
||||
let s = v.as_f64().ok_or_else(|| bad(CMD, "`secondsPerSlide` must be a number"))?;
|
||||
o.seconds_per_slide = check_seconds(s)?;
|
||||
}
|
||||
if let Some(v) = given(p, "loop") {
|
||||
o.loop_forever = match v {
|
||||
Value::Bool(b) => *b,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("forever") => true,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("once") => false,
|
||||
_ => return Err(bad(CMD, "`loop` must be true (forever), false (once), \"forever\" or \"once\"")),
|
||||
};
|
||||
}
|
||||
if let Some(v) = given(p, "slides") {
|
||||
let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?;
|
||||
if a.is_empty() {
|
||||
return Err(bad(CMD, "`slides` is empty"));
|
||||
}
|
||||
if a.len() > MAX_FRAMES {
|
||||
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides")));
|
||||
}
|
||||
let list = a
|
||||
.iter()
|
||||
.map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok()))
|
||||
.collect::<Option<Vec<usize>>>()
|
||||
.ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices (0, 1, 2…)"))?;
|
||||
o.slides = Some(list);
|
||||
}
|
||||
if let Some(v) = given(p, "includeHidden") {
|
||||
o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// The slides that become frames, in order: the chosen ones (or all), without hidden slides
|
||||
/// unless `include_hidden`.
|
||||
pub fn frames(&self, doc: &Presentation) -> Result<Vec<usize>> {
|
||||
let n = doc.slides.len();
|
||||
let chosen: Vec<usize> = match &self.slides {
|
||||
Some(list) => {
|
||||
if let Some(i) = list.iter().find(|i| **i >= n) {
|
||||
return Err(bad(CMD, format!("no slide {i} (the presentation has {n})")));
|
||||
}
|
||||
list.clone()
|
||||
}
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let frames: Vec<usize> = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect();
|
||||
if frames.is_empty() {
|
||||
return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)"));
|
||||
}
|
||||
if frames.len() > MAX_FRAMES {
|
||||
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides; choose a range")));
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Delay per frame in hundredths of a second.
|
||||
pub fn delay_cs(&self) -> Result<u16> {
|
||||
let s = check_seconds(self.seconds_per_slide)?;
|
||||
// In range by `check_seconds`: 10..=60_000.
|
||||
Ok((s * 100.0).round().clamp(1.0, f64::from(u16::MAX)) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame size for a slide of `doc`'s size at `width` px: the height follows the aspect ratio and
|
||||
/// no side exceeds [`MAX_SIDE`].
|
||||
pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) {
|
||||
let (sw, sh) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let aspect = if sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0 { sh / sw } else { 9.0 / 16.0 };
|
||||
let w = f64::from(width.clamp(MIN_WIDTH, MAX_WIDTH));
|
||||
let mut h = w * aspect;
|
||||
let mut w2 = w;
|
||||
if h > f64::from(MAX_SIDE) {
|
||||
w2 = w * f64::from(MAX_SIDE) / h;
|
||||
h = f64::from(MAX_SIDE);
|
||||
}
|
||||
let side = |v: f64| if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIDE)) as u32 } else { 1 };
|
||||
(side(w2), side(h))
|
||||
}
|
||||
|
||||
/// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and
|
||||
/// fully transparent pixels would otherwise become see-through).
|
||||
fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec<u8> {
|
||||
let len = (w as usize).saturating_mul(h as usize).saturating_mul(4);
|
||||
if img.width != w || img.height != h || img.pixels.len() != len {
|
||||
log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height);
|
||||
return vec![255; len];
|
||||
}
|
||||
let mut px = img.pixels.clone();
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let back = 255 - p[3];
|
||||
p[0] = p[0].saturating_add(back);
|
||||
p[1] = p[1].saturating_add(back);
|
||||
p[2] = p[2].saturating_add(back);
|
||||
p[3] = 255;
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
/// Render and encode the GIF.
|
||||
pub fn encode(doc: &Presentation, opts: &GifOptions) -> Result<GifOutput> {
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
|
||||
let frames = opts.frames(doc)?;
|
||||
let delay_cs = opts.delay_cs()?;
|
||||
let (w, h) = frame_size(doc, opts.width);
|
||||
let total = (frames.len() as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h));
|
||||
if total > MAX_TOTAL_PIXELS {
|
||||
return Err(bad(CMD, format!("{} slides at {w}×{h} is too large for a GIF; choose a smaller size or fewer slides", frames.len())));
|
||||
}
|
||||
let scale = f64::from(w) / doc.slide_size.width;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let enc_err = |e: image::ImageError| EngineError::Other(format!("GIF encoding failed: {e}"));
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut enc = GifEncoder::new_with_speed(&mut bytes, QUANTIZER_SPEED);
|
||||
// No repeat block at all means "play once"; a repeat count of 0 means "forever".
|
||||
if opts.loop_forever {
|
||||
enc.set_repeat(Repeat::Infinite).map_err(enc_err)?;
|
||||
}
|
||||
for i in &frames {
|
||||
let img = deckcraft_render::render_slide(doc, *i, &deckcraft_render::RenderOpts { scale, size: Some((w, h)), ..Default::default() });
|
||||
let buffer = image::RgbaImage::from_raw(w, h, on_white(&img, w, h))
|
||||
.ok_or_else(|| EngineError::Other(format!("GIF export: slide {i} has no pixels")))?;
|
||||
let delay = image::Delay::from_numer_denom_ms(u32::from(delay_cs) * 10, 1);
|
||||
enc.encode_frame(image::Frame::from_parts(buffer, 0, 0, delay)).map_err(enc_err)?;
|
||||
}
|
||||
}
|
||||
Ok(GifOutput { bytes, frames: frames.len(), width: w, height: h, delay_cs })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use image::AnimationDecoder;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
const COLOURS: [(&str, [u8; 3]); 4] =
|
||||
[("#E02020", [0xE0, 0x20, 0x20]), ("#20A040", [0x20, 0xA0, 0x40]), ("#2040E0", [0x20, 0x40, 0xE0]), ("#F0C000", [0xF0, 0xC0, 0x00])];
|
||||
|
||||
/// Four blank slides with solid backgrounds (red, green, blue, yellow); slide 2 (blue) hidden.
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"blank": true})).unwrap();
|
||||
while s.doc().unwrap().doc.slides.len() < COLOURS.len() {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
for (i, (hex, _)) in COLOURS.iter().enumerate() {
|
||||
s.execute("design.background", &json!({"color": hex, "index": i})).unwrap();
|
||||
}
|
||||
s.execute("slide.hide", &json!({"index": 2, "hidden": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> (image::metadata::LoopCount, Vec<image::Frame>) {
|
||||
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let count = dec.loop_count();
|
||||
(count, dec.into_frames().collect_frames().unwrap())
|
||||
}
|
||||
|
||||
/// The NETSCAPE2.0 application block's loop count, if the GIF has one.
|
||||
fn netscape_loops(bytes: &[u8]) -> Option<u16> {
|
||||
let tag = b"NETSCAPE2.0";
|
||||
let at = bytes.windows(tag.len()).position(|w| w == tag)?;
|
||||
// NETSCAPE2.0, sub-block size 3, id 1, loop count (little endian).
|
||||
let b = bytes.get(at + tag.len()..at + tag.len() + 4)?;
|
||||
Some(u16::from_le_bytes([b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn close(a: [u8; 4], b: [u8; 3]) -> bool {
|
||||
(0..3).all(|k| (i32::from(a[k]) - i32::from(b[k])).abs() <= 12) && a[3] == 255
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_one_frame_per_visible_slide() {
|
||||
let mut s = deck();
|
||||
let path = temp("deck.gif");
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "width": 320, "secondsPerSlide": 1.5})).unwrap();
|
||||
assert_eq!(r["frames"], 3, "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(netscape_loops(&bytes), Some(0), "loops forever by default");
|
||||
let (count, frames) = decode(&bytes);
|
||||
assert!(matches!(count, image::metadata::LoopCount::Infinite));
|
||||
assert_eq!(frames.len(), 3, "the hidden slide is skipped");
|
||||
// Slides are 16:9: 320×180.
|
||||
let expected = [COLOURS[0].1, COLOURS[1].1, COLOURS[3].1];
|
||||
for (f, want) in frames.iter().zip(expected) {
|
||||
assert_eq!((f.buffer().width(), f.buffer().height()), (320, 180));
|
||||
assert_eq!(f.delay().numer_denom_ms(), (1500, 1));
|
||||
let px = f.buffer().get_pixel(160, 90).0;
|
||||
assert!(close(px, want), "frame colour {px:?}, expected {want:?}");
|
||||
}
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180)));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_ranges_hidden_slides_and_play_once() {
|
||||
let s = deck();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let o = GifOptions::from_params(&json!({"width": "small", "loop": false, "slides": [3, 2, 1], "includeHidden": true})).unwrap();
|
||||
let out = encode(doc, &o).unwrap();
|
||||
assert_eq!((out.frames, out.width, out.height, out.delay_cs), (3, 640, 360, 500));
|
||||
assert_eq!(netscape_loops(&out.bytes), None, "play once: no repeat block");
|
||||
let (_, frames) = decode(&out.bytes);
|
||||
let want = [COLOURS[3].1, COLOURS[2].1, COLOURS[1].1];
|
||||
for (f, w) in frames.iter().zip(want) {
|
||||
assert!(close(f.buffer().get_pixel(320, 180).0, w));
|
||||
assert_eq!(f.delay().numer_denom_ms(), (5000, 1), "5 seconds by default");
|
||||
}
|
||||
// A range that holds only the hidden slide has nothing to export.
|
||||
let o = GifOptions::from_params(&json!({"slides": [2]})).unwrap();
|
||||
assert!(encode(doc, &o).is_err());
|
||||
for (preset, w) in SIZE_PRESETS {
|
||||
assert_eq!(GifOptions::from_params(&json!({"width": preset})).unwrap().width, w);
|
||||
}
|
||||
assert_eq!(
|
||||
GifOptions::from_params(&json!({"width": "Large", "loop": "once"})).unwrap(),
|
||||
GifOptions { width: 1280, loop_forever: false, ..Default::default() }
|
||||
);
|
||||
assert_eq!(GifOptions::from_params(&json!({"width": null, "secondsPerSlide": null})).unwrap(), GifOptions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors_not_panics() {
|
||||
for p in [
|
||||
json!({"width": 0}),
|
||||
json!({"width": 15}),
|
||||
json!({"width": 3841}),
|
||||
json!({"width": -5}),
|
||||
json!({"width": 1e308}),
|
||||
json!({"width": 640.5}),
|
||||
json!({"width": "huge"}),
|
||||
json!({"width": [640]}),
|
||||
json!({"secondsPerSlide": 0}),
|
||||
json!({"secondsPerSlide": 0.05}),
|
||||
json!({"secondsPerSlide": -1}),
|
||||
json!({"secondsPerSlide": 601}),
|
||||
json!({"secondsPerSlide": 1e308}),
|
||||
json!({"secondsPerSlide": "5"}),
|
||||
json!({"loop": 1}),
|
||||
json!({"loop": "twice"}),
|
||||
json!({"slides": []}),
|
||||
json!({"slides": [-1]}),
|
||||
json!({"slides": [0.5]}),
|
||||
json!({"slides": "1-3"}),
|
||||
json!({"slides": vec![0; MAX_FRAMES + 1]}),
|
||||
json!({"includeHidden": "yes"}),
|
||||
] {
|
||||
assert!(GifOptions::from_params(&p).is_err(), "{p}");
|
||||
}
|
||||
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MIN_POSITIVE, 1e-300] {
|
||||
assert!(check_seconds(v).is_err(), "{v}");
|
||||
let o = GifOptions { seconds_per_slide: v, ..Default::default() };
|
||||
assert!(o.delay_cs().is_err(), "{v}");
|
||||
}
|
||||
assert_eq!(GifOptions { seconds_per_slide: 600.0, ..Default::default() }.delay_cs().unwrap(), 60_000);
|
||||
assert_eq!(GifOptions { seconds_per_slide: 0.1, ..Default::default() }.delay_cs().unwrap(), 10);
|
||||
|
||||
// Through the command too: errors, the presentation untouched, no file written.
|
||||
let mut s = deck();
|
||||
let path = temp("hostile.gif");
|
||||
for p in [
|
||||
json!({"width": 99999}),
|
||||
json!({"secondsPerSlide": -3}),
|
||||
json!({"slides": [0, 99]}),
|
||||
json!({"slides": [18446744073709551615u64]}),
|
||||
json!({"slides": [2]}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["path"] = json!(path.to_string_lossy());
|
||||
assert!(s.execute("file.export", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(!path.exists());
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_are_bounded() {
|
||||
let mut s = deck();
|
||||
// A very tall slide is scaled down so no side exceeds MAX_SIDE.
|
||||
s.execute("design.slideSize", &json!({"w": 100.0, "h": 4000.0, "scale": "none"})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (w, h) = frame_size(doc, MAX_WIDTH);
|
||||
assert!(w >= 1 && h <= MAX_SIDE && w <= MAX_WIDTH, "{w}×{h}");
|
||||
// The total pixel budget stops huge requests before anything is rendered.
|
||||
let mut big = Presentation::clone(doc);
|
||||
big.slide_size = deckcraft_geom::Size::new(960.0, 540.0);
|
||||
let slide = big.slides[0].clone();
|
||||
big.slides = vec![slide; 200];
|
||||
let o = GifOptions { width: MAX_WIDTH, ..Default::default() };
|
||||
let e = encode(&big, &o).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
// A degenerate slide size falls back to 16:9 rather than dividing by zero.
|
||||
let mut odd = Presentation::clone(doc);
|
||||
odd.slide_size = deckcraft_geom::Size::new(0.0, f64::NAN);
|
||||
assert_eq!(frame_size(&odd, 640), (640, 360));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_is_also_a_save_format_and_inferred_from_the_path() {
|
||||
let s = deck();
|
||||
let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "gif").unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(crate::cmd::file::format_for_path("Talk.GIF"), "gif");
|
||||
let bytes = crate::cmd::file::gif_bytes(&s.doc().unwrap().doc, &json!({"width": 64, "slides": [0]})).unwrap();
|
||||
assert_eq!(decode(&bytes).1.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -290,15 +290,22 @@ impl Session {
|
||||
let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) };
|
||||
let Some((r, c)) = t.cell else { return Ok(Value::Null) };
|
||||
let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) };
|
||||
let (nr, nc) = (tb.n_rows(), tb.n_cols());
|
||||
let (nr, nc) = (tb.n_rows(), tb.n_cols().max(1));
|
||||
let idx = r * nc + c;
|
||||
if !back && idx + 1 >= nr * nc {
|
||||
// Tab in the last cell adds a row.
|
||||
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?;
|
||||
return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]}));
|
||||
}
|
||||
let ni = if back { idx.saturating_sub(1) } else { idx + 1 };
|
||||
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?;
|
||||
// Cells hidden under a merged cell are skipped.
|
||||
let visible = |i: &usize| tb.cell(i / nc, i % nc).is_some_and(|x| !x.is_covered());
|
||||
let next = if back { (0..idx).rev().find(visible) } else { (idx + 1..nr * nc).find(visible) };
|
||||
let ni = match next {
|
||||
Some(ni) => ni,
|
||||
None if !back => {
|
||||
// Tab in the last cell adds a row.
|
||||
let last = nr.saturating_sub(1);
|
||||
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": last}))?;
|
||||
return self.execute("text.edit", &json!({"id": t.shape, "cell": [last + 1, 0]}));
|
||||
}
|
||||
None => idx,
|
||||
};
|
||||
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc, ni % nc]}))?;
|
||||
self.execute("text.selectAll", &json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod compare;
|
||||
pub mod connect;
|
||||
pub mod drawtable;
|
||||
pub mod fonts;
|
||||
pub mod gif_export;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
pub mod inkshape;
|
||||
|
||||
+596
-1
@@ -293,7 +293,8 @@ fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() {
|
||||
assert_eq!(f("a.pdf"), Some("pdf"));
|
||||
assert_eq!(f("a.jpg"), Some("jpeg"));
|
||||
assert_eq!(f("a.txt"), Some("outline"));
|
||||
for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] {
|
||||
assert_eq!(f("a.gif"), Some("gif"));
|
||||
for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] {
|
||||
assert_eq!(f(path), None, "{path}");
|
||||
}
|
||||
}
|
||||
@@ -567,6 +568,300 @@ fn grown_table_frame_covers_its_drawn_rows() {
|
||||
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
|
||||
}
|
||||
|
||||
/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`.
|
||||
fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId {
|
||||
let rows = data.as_array().map_or(1, Vec::len);
|
||||
let w: f64 = cols.iter().sum();
|
||||
let r =
|
||||
s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
for (c, cw) in cols.iter().enumerate() {
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap();
|
||||
}
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table {
|
||||
match &s.doc().unwrap().shape(id).unwrap().kind {
|
||||
deckcraft_model::ShapeKind::Table(t) => t.clone(),
|
||||
k => panic!("not a table: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }
|
||||
}
|
||||
|
||||
fn text_sel(s: &Session) -> Option<TextSel> {
|
||||
s.doc().unwrap().selection.text.clone()
|
||||
}
|
||||
|
||||
/// Slide point of the caret before character `ch` of paragraph `para` in the text being edited.
|
||||
fn caret_point(s: &Session, para: usize, ch: usize) -> deckcraft_geom::Point {
|
||||
let st = s.doc().unwrap();
|
||||
text_point(st, st.selection.text.as_ref().unwrap(), para, ch)
|
||||
}
|
||||
|
||||
/// Slide point of the caret before character `ch` of paragraph `para` of text `t`.
|
||||
fn text_point(st: &DocState, t: &TextSel, para: usize, ch: usize) -> deckcraft_geom::Point {
|
||||
let (l, m) = cmd::text::text_frame(st, t).unwrap();
|
||||
let (x, top, bot) = l.caret(deckcraft_text::Pos { para, ch }).unwrap();
|
||||
m * deckcraft_geom::Point::new(x, (top + bot) / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_text_is_laid_out_where_the_cell_draws_it() {
|
||||
// The caret and hit testing of a cell's text use the cell's rect, not the table's.
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", ""]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap();
|
||||
let c = caret_point(&s, 0, 0);
|
||||
assert!(c.x > 300.0 && c.x < 320.0 && c.y > 100.0 && c.y < 140.0, "caret of cell (0, 1) at {c:?}");
|
||||
// An empty cell still has a caret.
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [1, 1]})).unwrap();
|
||||
let c = caret_point(&s, 0, 0);
|
||||
assert!(c.x > 300.0 && c.y > 140.0 && c.y < 180.0, "caret of empty cell (1, 1) at {c:?}");
|
||||
// Hit testing: the start and the end of "Hello world".
|
||||
let st = s.doc().unwrap();
|
||||
let t = TextSel { shape: id, cell: Some((0, 0)), ..Default::default() };
|
||||
assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(101.0, 115.0)), (0, 0));
|
||||
assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_in_a_cell_selects_the_word_clicked() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
|
||||
// Where "world" is drawn.
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let p = caret_point(&s, 0, 8);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// As the UI sends it: press, release, press, double-click (on release), then the release.
|
||||
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
|
||||
assert!(text_sel(&s).is_none(), "a click on an unselected table selects it");
|
||||
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))), "the word under the double-click");
|
||||
// The control channel sends the double-click alone.
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
assert_eq!(text_sel(&s).map(|t| t.ordered()), Some(((0, 6), (0, 11))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_while_editing_a_cell_move_the_caret_between_cells() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "Second cell"], ["c", "d"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap();
|
||||
let p6 = caret_point(&s, 0, 6);
|
||||
let p11 = caret_point(&s, 0, 11);
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap();
|
||||
// A click in another cell of the table moves the caret there, at the click.
|
||||
s.pointer(tev(PointerKind::Down, p6.x, p6.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.shape, t.cell, t.caret, t.anchor), (id, Some((0, 1)), (0, 6), (0, 6)));
|
||||
// Dragging selects within that cell, even past its edge.
|
||||
s.pointer(tev(PointerKind::Drag, p11.x, p11.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 600.0, p11.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 600.0, p11.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.ordered()), (Some((0, 1)), ((0, 6), (0, 11))));
|
||||
// Shift+click in another cell starts a new caret there instead of extending across cells.
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(PointerEvent { mods: shift, ..tev(PointerKind::Down, 150.0, 165.0) }).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, 165.0)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.is_range()), (Some((1, 0)), false));
|
||||
// Typing goes into the clicked cell.
|
||||
s.execute("text.insert", &json!({"text": "X"})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cell(1, 0).unwrap().text.text(), "cX");
|
||||
// A click outside the table stops editing.
|
||||
s.pointer(tev(PointerKind::Down, 50.0, 400.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 50.0, 400.0)).unwrap();
|
||||
assert!(text_sel(&s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_in_a_selected_table_enters_the_clicked_cell() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "Hello world"]]));
|
||||
s.pointer(tev(PointerKind::Down, 350.0, 160.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 350.0, 160.0)).unwrap();
|
||||
assert_eq!((s.doc().unwrap().selection.shapes.clone(), text_sel(&s)), (vec![id], None), "the first click selects the table");
|
||||
let st = s.doc().unwrap();
|
||||
let p = text_point(st, &TextSel { shape: id, cell: Some((1, 1)), ..Default::default() }, 0, 6);
|
||||
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.caret), (Some((1, 1)), (0, 6)), "caret at the click");
|
||||
// Dragging a selected table still moves it.
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.pointer(tev(PointerKind::Down, 150.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 170.0, 130.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 170.0, 130.0)).unwrap();
|
||||
assert!(text_sel(&s).is_none());
|
||||
assert_ne!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().x, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_moves_by_line_in_a_wrapped_cell() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[90.0, 200.0], json!([["one two three four five six seven", "b"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0], "at": [0, 0]})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "down"})).unwrap();
|
||||
let down = text_sel(&s).unwrap().caret;
|
||||
assert!(down.0 == 0 && down.1 > 0, "down goes to the next line of the paragraph: {down:?}");
|
||||
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
|
||||
let end = text_sel(&s).unwrap().caret;
|
||||
assert!(end.1 > down.1 && end.1 < 33, "end of the second line: {end:?}");
|
||||
s.execute("text.move", &json!({"to": "up"})).unwrap();
|
||||
assert!(text_sel(&s).unwrap().caret.1 < down.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_borders_are_hit_where_they_are_drawn() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
|
||||
let hit = |s: &Session, x: f64, y: f64| s.hit_test(deckcraft_geom::Point::new(x, y), 3.0);
|
||||
assert_eq!(hit(&s, 201.0, 125.0), Some(tools::Hit::TableColumn { id, col: 0 }));
|
||||
assert_eq!(hit(&s, 400.0, 125.0), Some(tools::Hit::TableColumn { id, col: 2 }), "the right edge");
|
||||
assert_eq!(hit(&s, 150.0, 139.0), Some(tools::Hit::TableRow { id, row: 0 }));
|
||||
assert_eq!(hit(&s, 150.0, 181.0), Some(tools::Hit::TableRow { id, row: 1 }), "the bottom edge");
|
||||
assert!(matches!(hit(&s, 100.0, 125.0), Some(tools::Hit::Shape { .. })), "the left edge moves the table");
|
||||
assert!(matches!(hit(&s, 150.0, 125.0), Some(tools::Hit::Shape { .. })));
|
||||
// Hovering a border shows it (the UI picks the resize cursor from the hover).
|
||||
s.pointer(tev(PointerKind::Move, 300.0, 170.0)).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::TableColumn { id, col: 1 }));
|
||||
// A border inside a merged cell isn't there.
|
||||
s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap();
|
||||
assert!(matches!(hit(&s, 200.0, 120.0), Some(tools::Hit::Shape { .. })));
|
||||
assert_eq!(hit(&s, 200.0, 160.0), Some(tools::Hit::TableColumn { id, col: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_column_borders_resizes_columns() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
|
||||
let undo_depth = s.doc().unwrap().history.undo.len();
|
||||
// An inner border: the two columns share the change, the table keeps its width.
|
||||
s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 215.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 230.0, 128.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 230.0, 128.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![130.0, 70.0, 100.0]);
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 300.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step per drag");
|
||||
// Columns don't collapse.
|
||||
s.pointer(tev(PointerKind::Down, 230.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 500.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 500.0, 125.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH, 100.0]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 100.0]);
|
||||
// The right edge: the last column and the table get wider.
|
||||
s.pointer(tev(PointerKind::Down, 400.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 450.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 450.0, 120.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 150.0]);
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 350.0);
|
||||
// A click on a border without dragging changes nothing.
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 200.0, 125.0)).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_row_border_sets_the_row_height() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let drawn = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
tools::table_rows(st, &table_of(s, id))
|
||||
};
|
||||
s.pointer(tev(PointerKind::Down, 150.0, 140.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 150.0, 170.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, 170.0)).unwrap();
|
||||
assert_eq!(drawn(&s), vec![70.0, 40.0]);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()).h, 110.0, "the table grows");
|
||||
// Rows still fit their text: dragging the border up stops at the text.
|
||||
s.pointer(tev(PointerKind::Down, 150.0, 170.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 150.0, 60.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, 60.0)).unwrap();
|
||||
let h = drawn(&s)[0];
|
||||
assert!(h > 10.0 && h < 40.0, "row 0 shrinks to its text: {h}");
|
||||
// The bottom edge sizes the last row.
|
||||
let bottom = 100.0 + drawn(&s).iter().sum::<f64>();
|
||||
s.pointer(tev(PointerKind::Down, 150.0, bottom)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 150.0, bottom + 20.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, bottom + 20.0)).unwrap();
|
||||
assert_eq!(drawn(&s)[1], 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_border_while_editing_a_cell_keeps_editing() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 1]})).unwrap();
|
||||
s.pointer(tev(PointerKind::Down, 200.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 180.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 180.0, 120.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![80.0, 120.0]);
|
||||
assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_width_can_keep_the_table_width() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"]]));
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 150.0, "keepTotal": true})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 150.0, 50.0]);
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 1000.0, "keepTotal": true})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH]);
|
||||
// The last column has no neighbour: the table changes width.
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": 2, "width": 80.0, "keepTotal": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 100.0 + (200.0 - cmd::table::MIN_COLUMN_WIDTH) + 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_tables_hit_the_cell_under_the_pointer() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": 90})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let f = cmd::table::table_frame(st, st.shape(id).unwrap()).unwrap();
|
||||
let p = f.affine * deckcraft_geom::Point::new(150.0, 60.0);
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_moves_between_cells_skipping_merged_ones() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
|
||||
s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let cell = |s: &Session| text_sel(s).and_then(|t| t.cell);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.key("Tab", Mods::default()).unwrap();
|
||||
assert_eq!(cell(&s), Some((0, 2)), "the cell under the merge is skipped");
|
||||
s.key("Tab", shift).unwrap();
|
||||
assert_eq!(cell(&s), Some((0, 0)));
|
||||
s.key("Tab", shift).unwrap();
|
||||
assert_eq!(cell(&s), Some((0, 0)), "Shift+Tab in the first cell stays");
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [1, 2]})).unwrap();
|
||||
s.key("Tab", Mods::default()).unwrap();
|
||||
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
@@ -2362,6 +2657,306 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
|
||||
}
|
||||
|
||||
/// A blank slide holding one text box being edited, with smart guides and grid snapping off so
|
||||
/// drags move by exactly the pointer delta. Returns the session, the box id and its box.
|
||||
fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap();
|
||||
if rot != 0.0 {
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap();
|
||||
}
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let x = text_box_xfrm(&s, id);
|
||||
(s, id, x)
|
||||
}
|
||||
|
||||
/// Slide point for a point in the box's own (rotated) frame.
|
||||
fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) {
|
||||
let p = x.affine() * deckcraft_geom::Point::new(lx, ly);
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
let down = s.pointer(ev(PointerKind::Down, from)).unwrap();
|
||||
let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0);
|
||||
s.pointer(ev(PointerKind::Drag, mid)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, to)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, to)).unwrap();
|
||||
down
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_frame_of_edited_text_moves_the_box() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
assert!(x.h > 12.0, "{x:?}");
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
// Hovering the band on either side of the edge says "move"; the inside says "text".
|
||||
for ly in [-2.0, 2.0, x.h + 2.0] {
|
||||
let (px, py) = on_box(&x, x.w / 4.0, ly);
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}");
|
||||
}
|
||||
let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0);
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true }));
|
||||
|
||||
// Press on the top edge, just outside, and drag: the box moves, text editing ends.
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let from = on_box(&x, x.w / 4.0, -1.0);
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
|
||||
assert_eq!(down["hit"], json!({"kind": "frame", "id": id}));
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
|
||||
assert_eq!((moved.w, moved.h), (x.w, x.h));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
// One undo step, which puts the box back.
|
||||
assert_eq!(st.history.undo.len(), undo_before + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
|
||||
// The inner side of the band works too; a click there without a drag selects the box whole
|
||||
// and changes nothing.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0);
|
||||
let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
s.pointer(ev(PointerKind::Down)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up)).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
assert_eq!(st.history.undo.len(), undo_before);
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inside_edited_text_selects_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0));
|
||||
assert!(down.get("caret").is_some(), "{down}");
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.as_ref().expect("still editing");
|
||||
assert!(t.is_range(), "{t:?}");
|
||||
assert_eq!(t.ordered(), ((0, 0), (0, 11)));
|
||||
assert_eq!(st.history.undo.len(), undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_of_rotated_edited_text_follows_the_rotation() {
|
||||
let (mut s, id, x) = editing_text_box(30.0);
|
||||
assert_eq!(x.rot, 30.0);
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
// The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation.
|
||||
let from = on_box(&x, x.w / 4.0, x.h + 1.0);
|
||||
assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid }));
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0));
|
||||
assert_eq!(down["hit"]["kind"], "frame");
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}");
|
||||
assert_eq!(moved.rot, 30.0);
|
||||
// Inside the rotated box, pressing still places the caret.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let mx = text_box_xfrm(&s, id);
|
||||
let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0);
|
||||
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert!(r.get("caret").is_some(), "{r}");
|
||||
assert_eq!(text_box_xfrm(&s, id), mx);
|
||||
// Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press
|
||||
// there leaves editing instead of moving the caret.
|
||||
let off = on_box(&mx, mx.w / 2.0, -mx.h);
|
||||
let off_pt = deckcraft_geom::Point::new(off.0, off.1);
|
||||
assert!(mx.bounds().contains(off_pt));
|
||||
assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid }));
|
||||
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert!(r.get("caret").is_none(), "{r}");
|
||||
assert!(s.doc().unwrap().selection.text.is_none());
|
||||
assert_eq!(text_box_xfrm(&s, id), mx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_handles_beat_the_frame_of_edited_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
// The SE corner lies in the frame band, but its handle wins.
|
||||
let from = on_box(&x, x.w, x.h);
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1));
|
||||
assert_eq!(down["hit"]["kind"], "resize");
|
||||
let sized = text_box_xfrm(&s, id);
|
||||
assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}");
|
||||
assert_eq!((sized.x, sized.y), (x.x, x.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_language_marks_the_selected_text() {
|
||||
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": "Hola mundo"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap();
|
||||
s.execute("format.language", &json!({"lang": "es-ES"})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES");
|
||||
let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
|
||||
let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect();
|
||||
assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_params_doc_names_its_keys() {
|
||||
for c in command_specs() {
|
||||
assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params));
|
||||
}
|
||||
assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]);
|
||||
assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_params_doc_spells_out_each_parameter() {
|
||||
use cmd::Param;
|
||||
let p = |name, optional, about, or, object| Param { name, optional, about, or, object };
|
||||
assert_eq!(
|
||||
cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(),
|
||||
[
|
||||
p("preset", false, "rect|roundRect", false, 0),
|
||||
p("rect", true, "[x,y,w,h] pt", false, 0),
|
||||
p("index", true, "(else selected shapes)", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(),
|
||||
[
|
||||
p("color", true, "", false, 0),
|
||||
p("none", true, "true", true, 0),
|
||||
p("gradient", true, "{stops, kind?: a|b}", false, 0),
|
||||
p("ids", true, "", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cmd::parse_params("{name} | {major, minor}").unwrap(),
|
||||
[p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_command_acts_on_names_only_keys_it_takes() {
|
||||
for c in command_specs() {
|
||||
let keys = cmd::param_keys(c.params).unwrap();
|
||||
let said = c.acts_on().unwrap_or_default();
|
||||
assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id);
|
||||
assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id);
|
||||
}
|
||||
let acts = |id: &str| find_command(id).and_then(|c| c.acts_on());
|
||||
assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes."));
|
||||
assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes."));
|
||||
assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes."));
|
||||
assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes."));
|
||||
assert_eq!(acts("slide.new"), None);
|
||||
}
|
||||
|
||||
/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the
|
||||
/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land
|
||||
/// in the cell where it is drawn, and borders still resize.
|
||||
#[test]
|
||||
fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let built_in = caret_point(&s, 0, 8);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
// The table's style, defined by the file: a plain (not bold) header, a filled body, a
|
||||
// background.
|
||||
{
|
||||
let style = table_of(&s, id).style;
|
||||
let d = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b))));
|
||||
doc.table_styles.push(TableStyleDef {
|
||||
id: style,
|
||||
name: "File style".into(),
|
||||
parts: vec![
|
||||
(TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }),
|
||||
(TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }),
|
||||
],
|
||||
background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }),
|
||||
raw: String::new(),
|
||||
});
|
||||
}
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let p = caret_point(&s, 0, 8);
|
||||
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// A double-click selects the word drawn there.
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))));
|
||||
// A click in another cell moves the caret there.
|
||||
s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap();
|
||||
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
|
||||
// Border drag, while editing: the columns resize, editing goes on.
|
||||
s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]);
|
||||
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_notes_keeps_their_formatting_through_pptx() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
// A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint.
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() },
|
||||
point("First point"),
|
||||
point("Second point"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = deckcraft_pptx::export(&p).unwrap();
|
||||
let mut s = session();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap();
|
||||
// Type a word into the second bullet, as the notes pane does.
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap();
|
||||
let check = |notes: &TextBody| {
|
||||
assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point");
|
||||
assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
for para in ¬es.paragraphs[1..] {
|
||||
assert_eq!(para.level, 1);
|
||||
assert_eq!(para.props.margin_left, Some(22.5));
|
||||
assert_eq!(para.props.indent, Some(-22.5));
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() }));
|
||||
}
|
||||
};
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap();
|
||||
check(&reopened.slides[0].notes);
|
||||
// slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are.
|
||||
let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone();
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain");
|
||||
s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap();
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_options_as_you_type() {
|
||||
let mut s = session();
|
||||
|
||||
+233
-80
@@ -110,6 +110,21 @@ pub enum Hit {
|
||||
id: ShapeId,
|
||||
text: bool,
|
||||
},
|
||||
/// The frame of the box whose text is being edited (a band on either side of its edge):
|
||||
/// pressing there leaves text editing and drags the box, like PowerPoint.
|
||||
Frame {
|
||||
id: ShapeId,
|
||||
},
|
||||
/// The right edge of table column `col` (dragging it resizes the column).
|
||||
TableColumn {
|
||||
id: ShapeId,
|
||||
col: usize,
|
||||
},
|
||||
/// The bottom edge of table row `row` (dragging it resizes the row).
|
||||
TableRow {
|
||||
id: ShapeId,
|
||||
row: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -177,6 +192,18 @@ enum Drag {
|
||||
TextSelect {
|
||||
shape: ShapeId,
|
||||
},
|
||||
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
|
||||
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
|
||||
/// drawn height then, `frame` where the table was drawn.
|
||||
TableBorder {
|
||||
id: ShapeId,
|
||||
column: bool,
|
||||
index: usize,
|
||||
start: Point,
|
||||
size: f64,
|
||||
frame: Affine,
|
||||
moved: bool,
|
||||
},
|
||||
Ink {
|
||||
points: Vec<(f64, f64, f32)>,
|
||||
},
|
||||
@@ -382,6 +409,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The frame of the text being edited: grabbing it moves the box.
|
||||
if let Some(e) = edited_box(st)
|
||||
&& e.movable
|
||||
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
|
||||
{
|
||||
return Some(Hit::Frame { id: e.id });
|
||||
}
|
||||
// Shapes, topmost first.
|
||||
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||
for sh in st.shapes().iter().rev() {
|
||||
@@ -510,30 +544,10 @@ impl Session {
|
||||
}
|
||||
ToolKind::Select => {}
|
||||
}
|
||||
let st = self.doc()?;
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection.
|
||||
if let Some(t) = st.selection.text.clone()
|
||||
&& !t.notes
|
||||
&& t.cell.is_none()
|
||||
&& let Some(sh) = st.shape(t.shape)
|
||||
{
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if x.bounds().inflate(tol, tol).contains(p)
|
||||
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. }))
|
||||
{
|
||||
let pos = text_pos(st, sh, p);
|
||||
let shift = mods.shift;
|
||||
self.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = pos;
|
||||
if !shift {
|
||||
tt.anchor = pos;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::TextSelect { shape: t.shape });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
|
||||
self.tool.drag = Some(Drag::TextSelect { shape });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
// Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;
|
||||
@@ -567,6 +581,8 @@ impl Session {
|
||||
}
|
||||
let st = self.doc()?;
|
||||
match hit {
|
||||
Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?,
|
||||
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
|
||||
Some(Hit::Resize { id, handle }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
self.begin("Size");
|
||||
@@ -592,6 +608,17 @@ impl Session {
|
||||
self.begin("Adjust Shape");
|
||||
self.tool.drag = Some(Drag::Adjust { id, index, orig });
|
||||
}
|
||||
Some(Hit::Frame { id }) => {
|
||||
// Like PowerPoint: the box leaves text editing and is selected whole (a click
|
||||
// without a drag stops there), and dragging moves it as one undo step.
|
||||
let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });
|
||||
}
|
||||
Some(Hit::Warp { id, index }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
let found = st.shape(id).and_then(|sh| {
|
||||
@@ -624,8 +651,10 @@ impl Session {
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||
// placeholders take the caret straight away like PowerPoint.
|
||||
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a selected
|
||||
// table puts the caret in the clicked cell.
|
||||
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
||||
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
@@ -874,9 +903,10 @@ impl Session {
|
||||
self.tool.preview = Some(x);
|
||||
}
|
||||
Drag::TextSelect { shape } => {
|
||||
// The selection stays in the shape or table cell where it started.
|
||||
let st = self.doc()?;
|
||||
if let Some(sh) = st.shape(shape) {
|
||||
let pos = text_pos(st, sh, p);
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
let pos = cmd::text::text_pos_at(st, t, p);
|
||||
self.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.caret = pos;
|
||||
@@ -884,6 +914,26 @@ impl Session {
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Drag::TableBorder { id, column, index, start, size, frame, moved } => {
|
||||
let local = frame.inverse() * p;
|
||||
let d = if column { local.x - start.x } else { local.y - start.y };
|
||||
if !moved && d.abs() < tol.max(1.0) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
self.begin(if column { "Column Width" } else { "Row Height" });
|
||||
}
|
||||
// An inner column border moves between its two columns (the table keeps its
|
||||
// width); the right edge widens the last column and the table. A row's height is
|
||||
// a minimum: rows still grow to fit their text.
|
||||
if column {
|
||||
let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH);
|
||||
self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?;
|
||||
} else {
|
||||
self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?;
|
||||
}
|
||||
self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true });
|
||||
}
|
||||
Drag::Freeform { mode, mut points, pressed } => {
|
||||
// Dragging scribbles (except for curves, which only take clicked points).
|
||||
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
@@ -939,11 +989,8 @@ impl Session {
|
||||
};
|
||||
}
|
||||
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
|
||||
// Plain click on text: caret at the click.
|
||||
let st = self.doc()?;
|
||||
if let Some(sh) = st.shape(id) {
|
||||
let pos = text_pos(st, sh, at);
|
||||
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
|
||||
// Plain click on text: caret at the click (in a table, in the clicked cell).
|
||||
if self.edit_text_at(id, at)? {
|
||||
out = json!({"editing": id});
|
||||
}
|
||||
}
|
||||
@@ -1073,8 +1120,12 @@ impl Session {
|
||||
self.tool.sticky = false;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let st = self.doc()?;
|
||||
if st.selection.text.is_some() {
|
||||
// The double-click's second press started a click (caret on release): this replaces it.
|
||||
if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) {
|
||||
self.tool.drag = None;
|
||||
}
|
||||
// In the text being edited: select the word double-clicked.
|
||||
if self.place_caret(p, tol, false)?.is_some() {
|
||||
return self.execute("text.selectWord", &json!({}));
|
||||
}
|
||||
match self.hit_test(p, tol) {
|
||||
@@ -1082,18 +1133,12 @@ impl Session {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
|
||||
match &sh.kind {
|
||||
ShapeKind::Table(_) => {
|
||||
let x = xfrm_of(&st.doc, &st.selection, &sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
|
||||
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
|
||||
ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
|
||||
// Caret at the double-click (in a table, in the clicked cell), then select
|
||||
// the word there.
|
||||
if !self.edit_text_at(id, p)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
|
||||
let pos = text_pos(st, &sh, p);
|
||||
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
|
||||
self.execute("text.selectWord", &json!({}))
|
||||
}
|
||||
ShapeKind::Group { .. } => {
|
||||
@@ -1147,6 +1192,9 @@ impl Session {
|
||||
| Hit::LineEnd { id, .. }
|
||||
| Hit::Handle { id, .. }
|
||||
| Hit::Vertex { id, .. }
|
||||
| Hit::Frame { id }
|
||||
| Hit::TableColumn { id, .. }
|
||||
| Hit::TableRow { id, .. }
|
||||
| Hit::Warp { id, .. },
|
||||
) => Some(*id),
|
||||
None => None,
|
||||
@@ -1166,6 +1214,96 @@ impl Session {
|
||||
}))
|
||||
}
|
||||
|
||||
/// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection
|
||||
/// grows to it). In a table being edited, a click in another cell moves the caret into that
|
||||
/// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text
|
||||
/// (or is on a handle or a table border).
|
||||
fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result<Option<(ShapeId, (usize, usize))>> {
|
||||
let st = self.doc()?;
|
||||
let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) };
|
||||
let Some(sh) = st.shape(t.shape) else { return Ok(None) };
|
||||
// A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`).
|
||||
// A table cell: anywhere over the table.
|
||||
let inside = match t.cell {
|
||||
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
|
||||
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
|
||||
};
|
||||
if !inside
|
||||
|| matches!(
|
||||
self.hit_test(p, tol),
|
||||
Some(
|
||||
Hit::Resize { .. }
|
||||
| Hit::Rotate { .. }
|
||||
| Hit::Adjust { .. }
|
||||
| Hit::Warp { .. }
|
||||
| Hit::Frame { .. }
|
||||
| Hit::TableColumn { .. }
|
||||
| Hit::TableRow { .. }
|
||||
)
|
||||
)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let old_cell = t.cell;
|
||||
if old_cell.is_some()
|
||||
&& let Some(f) = cmd::table::table_frame(st, sh)
|
||||
{
|
||||
t.cell = f.cell_at(f.local(p)).or(old_cell);
|
||||
}
|
||||
let pos = cmd::text::text_pos_at(st, &t, p);
|
||||
// A selection can't span cells: a click in another cell starts afresh.
|
||||
let extend = extend && t.cell == old_cell;
|
||||
let cell = t.cell;
|
||||
self.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.cell = cell;
|
||||
tt.caret = pos;
|
||||
if !extend {
|
||||
tt.anchor = pos;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Some((t.shape, pos)))
|
||||
}
|
||||
|
||||
/// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell
|
||||
/// at `p`. Returns whether editing started.
|
||||
fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result<bool> {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id) else { return Ok(false) };
|
||||
let params = match cmd::table::table_frame(st, sh) {
|
||||
Some(f) => {
|
||||
let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) };
|
||||
let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p);
|
||||
json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]})
|
||||
}
|
||||
None => {
|
||||
let pos = text_pos(st, sh, p);
|
||||
json!({"id": id, "at": [pos.0, pos.1]})
|
||||
}
|
||||
};
|
||||
self.execute("text.edit", ¶ms)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Press on a table border: dragging it resizes the column or row. Editing a cell of the
|
||||
/// table goes on; otherwise the table becomes the selection.
|
||||
fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> {
|
||||
let st = self.doc()?;
|
||||
let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) };
|
||||
let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0);
|
||||
let (start, frame) = (f.local(p), f.affine);
|
||||
if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) {
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
}
|
||||
self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_picture(&self, id: ShapeId) -> bool {
|
||||
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
|
||||
}
|
||||
@@ -1504,6 +1642,46 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option<S
|
||||
None
|
||||
}
|
||||
|
||||
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
|
||||
struct EditedBox {
|
||||
id: ShapeId,
|
||||
xfrm: Xfrm,
|
||||
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
|
||||
let t = st.selection.text.as_ref()?;
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })
|
||||
}
|
||||
|
||||
/// Where a point falls against the box of the text being edited.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EditZone {
|
||||
/// Well inside: the caret goes there.
|
||||
Inside,
|
||||
/// On the frame: a band `tol` wide on either side of the box edge.
|
||||
Frame,
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside
|
||||
/// small boxes the band narrows to a quarter of the box so their text stays reachable.
|
||||
fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone {
|
||||
let l = x.affine().inverse() * p;
|
||||
let (w, h) = (x.w.max(0.0), x.h.max(0.0));
|
||||
let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 };
|
||||
if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol {
|
||||
return EditZone::Outside;
|
||||
}
|
||||
let inner = tol.min(w / 4.0).min(h / 4.0);
|
||||
if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame }
|
||||
}
|
||||
|
||||
fn contains_rect(outer: Rect, inner: Rect) -> bool {
|
||||
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
|
||||
}
|
||||
@@ -1617,6 +1795,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let inv = x.affine().inverse();
|
||||
let local = inv * p;
|
||||
// Table borders resize rows and columns.
|
||||
let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol;
|
||||
if near && let Some(f) = cmd::table::table_frame(st, sh) {
|
||||
match f.border_at(f.local(p), tol) {
|
||||
Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }),
|
||||
Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
match &sh.kind {
|
||||
ShapeKind::Group { children, child } => {
|
||||
// A group is hit when any child is hit; the group is what gets selected.
|
||||
@@ -1685,18 +1872,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
|
||||
|
||||
/// Text position under slide point `p` in shape `sh`.
|
||||
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let t = TextSel { shape: sh.id, ..Default::default() };
|
||||
match cmd::text::layout_for(st, &t) {
|
||||
Some(l) => {
|
||||
// The text's own transform: flips don't mirror text, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
|
||||
let h = l.hit(local);
|
||||
(h.para, h.ch)
|
||||
}
|
||||
None => (0, 0),
|
||||
}
|
||||
cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p)
|
||||
}
|
||||
|
||||
/// Row heights of table `t` as drawn: rows grow to fit their text.
|
||||
@@ -1704,29 +1880,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64>
|
||||
cmd::table_drawn_heights(&st.doc, &st.selection, t)
|
||||
}
|
||||
|
||||
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
|
||||
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
|
||||
let mut y = 0.0;
|
||||
let mut row = heights.len().saturating_sub(1);
|
||||
for (i, h) in heights.iter().enumerate() {
|
||||
if p.y < y + h {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
y += h;
|
||||
}
|
||||
let mut x = 0.0;
|
||||
let mut col = t.cols.len().saturating_sub(1);
|
||||
for (i, w) in t.cols.iter().enumerate() {
|
||||
if p.x < x + w {
|
||||
col = i;
|
||||
break;
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
(row, col)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -22,9 +22,10 @@ const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone)
|
||||
current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \
|
||||
list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \
|
||||
{layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \
|
||||
inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \
|
||||
run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \
|
||||
on `ids`. Save with save_presentation (.deckcraft or .pptx).";
|
||||
inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \
|
||||
current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \
|
||||
the selected text or shapes; shape.fill, shape.line and the other shape commands whose parameters list `ids` act on \
|
||||
those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx).";
|
||||
|
||||
/// Resource URIs.
|
||||
pub const DOC_URI: &str = "deckcraft://document";
|
||||
|
||||
@@ -216,3 +216,29 @@ fn export_rejects_unknown_format() {
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("docx"), "{r}");
|
||||
assert!(!path.exists(), "nothing may be written for an unknown format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_gif_and_its_options() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let tools = call(&mut s, 1, "tools/list", json!({}));
|
||||
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
|
||||
assert!(export["description"].as_str().unwrap().contains("gif"), "{export}");
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert!(props["format"]["enum"].as_array().unwrap().iter().any(|x| x == "gif"), "{export}");
|
||||
assert_eq!(props["width"]["oneOf"][1]["enum"], json!(["small", "medium", "large", "extraLarge"]));
|
||||
for p in ["secondsPerSlide", "loop", "slides", "includeHidden"] {
|
||||
assert!(props.get(p).is_some(), "{p}: {export}");
|
||||
}
|
||||
// What the schema describes is accepted, and bad values are tool errors, not crashes.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
tool(&mut s, "new_presentation", json!({}));
|
||||
for width in [json!("small"), json!(200)] {
|
||||
let path = dir.join("deck.gif");
|
||||
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "width": width, "secondsPerSlide": 2, "loop": false, "slides": [0]}));
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a"), "{width}");
|
||||
}
|
||||
let r = call(&mut s, 9, "tools/call", json!({"name": "export", "arguments": {"path": dir.join("bad.gif").to_string_lossy(), "width": 0}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+14
-5
@@ -115,23 +115,32 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), or an animated gif of the deck (one frame per slide).",
|
||||
obj(
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
"layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
|
||||
"perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
|
||||
"dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"},
|
||||
"includeHidden": boolean("PDF: include hidden slides (default false)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"},
|
||||
"includeHidden": boolean("PDF and GIF: include hidden slides (default false)"),
|
||||
"textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
|
||||
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"),
|
||||
"paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"},
|
||||
"orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"}
|
||||
"orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"},
|
||||
"width": {
|
||||
"oneOf": [
|
||||
{"type": "integer", "minimum": 16, "maximum": 3840},
|
||||
{"type": "string", "enum": ["small", "medium", "large", "extraLarge"]}
|
||||
],
|
||||
"description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; the height follows the slide's shape"
|
||||
},
|
||||
"secondsPerSlide": {"type": "number", "minimum": 0.1, "maximum": 600, "description": "GIF: seconds each slide shows (default 5)"},
|
||||
"loop": boolean("GIF: loop forever (true, default) or play once (false)")
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
|
||||
@@ -401,6 +401,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of the `n`th character of the body's plain text (`\n` between paragraphs).
|
||||
fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
|
||||
for (pi, p) in body.paragraphs.iter().enumerate() {
|
||||
let len = p.char_len();
|
||||
if n <= len {
|
||||
return (pi, n);
|
||||
}
|
||||
n -= len + 1;
|
||||
}
|
||||
end(body)
|
||||
}
|
||||
|
||||
/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in
|
||||
/// place: only the span between the longest common prefix and suffix changes, so untouched text
|
||||
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
|
||||
/// that shares nothing with the old text replaces it as plain text.
|
||||
pub fn replace_text(body: &mut TextBody, text: &str) {
|
||||
let old: Vec<char> = body.text().chars().collect();
|
||||
let new: Vec<char> = text.chars().collect();
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
|
||||
let room = old.len().min(new.len()) - pre;
|
||||
let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count();
|
||||
if pre == 0 && suf == 0 {
|
||||
*body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) };
|
||||
return;
|
||||
}
|
||||
let a = pos_of_char(body, pre);
|
||||
let b = pos_of_char(body, old.len() - suf);
|
||||
let at = delete(body, a, b);
|
||||
let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect();
|
||||
if !mid.is_empty() {
|
||||
insert(body, at, &mid, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the body.
|
||||
pub fn end(body: &TextBody) -> Pos {
|
||||
let pi = body.paragraphs.len().saturating_sub(1);
|
||||
@@ -676,4 +714,53 @@ mod tests {
|
||||
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||
assert_eq!(next_case("123"), "upper");
|
||||
}
|
||||
|
||||
/// Speaker notes: a bold heading over indented bullet points.
|
||||
fn notes() -> TextBody {
|
||||
use crate::text::{Bullet, ParaProps};
|
||||
let mut b = body("Overview\nFirst point\nSecond point");
|
||||
format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true));
|
||||
for p in b.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
p.props =
|
||||
ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_keeps_formatting() {
|
||||
let before = notes();
|
||||
// Same text: nothing changes.
|
||||
let mut b = before.clone();
|
||||
replace_text(&mut b, "Overview\nFirst point\nSecond point");
|
||||
assert_eq!(b, before);
|
||||
// A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet.
|
||||
replace_text(&mut b, "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.text(), "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props);
|
||||
assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1));
|
||||
// Typing at the end of the heading continues it in bold.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter after a bullet starts another bullet; deleting one joins nothing else.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point");
|
||||
assert_eq!(b.paragraphs.len(), 4);
|
||||
assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props));
|
||||
replace_text(&mut b, "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.text(), "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].level, 1);
|
||||
// Text with nothing in common is new plain text.
|
||||
replace_text(&mut b, "Plain");
|
||||
assert_eq!(b, body("Plain"));
|
||||
replace_text(&mut b, "");
|
||||
assert!(b.is_empty());
|
||||
// Into an empty body.
|
||||
let mut e = TextBody::default();
|
||||
replace_text(&mut e, "a\nb");
|
||||
assert_eq!(e.text(), "a\nb");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with
|
||||
//! custom bullets, plain lines.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::defaults;
|
||||
use deckcraft_model::text::{Bullet, ParaProps, TextBody};
|
||||
|
||||
const NOTES: &str = "ppt/notesSlides/notesSlide1.xml";
|
||||
|
||||
/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain
|
||||
/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold
|
||||
/// label and plain text), a level-2 paragraph with buNone, and a closing plain line.
|
||||
const NOTES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Metodický prehľad</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Žiaci pracujú vo dvojiciach.</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Cieľ: </a:t></a:r><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>vysvetliť cyklus</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" i="1" dirty="0"/><a:t>Reflexia</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buNone/></a:pPr><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Bez odrážky</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Pomôcky: počítač</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>"#;
|
||||
|
||||
/// A deck with one notes slide, its notes replaced by [`NOTES_XML`].
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder");
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut found = false;
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
if name == NOTES {
|
||||
data = NOTES_XML.as_bytes().to_vec();
|
||||
found = true;
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
assert!(found, "the export has no {NOTES}");
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn assert_formatted(notes: &TextBody) {
|
||||
assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač");
|
||||
let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") };
|
||||
assert_eq!(head.runs[0].props.bold, Some(true), "bold heading");
|
||||
for para in [head, plain, closing] {
|
||||
assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text());
|
||||
}
|
||||
assert_eq!(plain.runs[0].props.bold, None);
|
||||
for para in [goal, reflect] {
|
||||
assert_eq!(para.level, 1, "level 2");
|
||||
assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU");
|
||||
assert_eq!(para.props.indent, Some(-22.5), "hanging indent");
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() }));
|
||||
assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings"));
|
||||
}
|
||||
assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label");
|
||||
assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain");
|
||||
assert_eq!(reflect.runs[0].props.italic, Some(true));
|
||||
assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_import_keeps_bold_levels_and_indents() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
assert_formatted(&p.slides[0].notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_formatting_survives_a_round_trip() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
|
||||
assert_formatted(&again.slides[0].notes);
|
||||
// And the written XML says so, for PowerPoint.
|
||||
let bytes = deckcraft_pptx::export(&again).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8");
|
||||
assert!(xml.contains(r#"b="1""#), "{xml}");
|
||||
assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:buFont typeface="Wingdings""#) && xml.contains("<a:buChar") && xml.contains("<a:buNone/>"), "{xml}");
|
||||
}
|
||||
@@ -1170,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
|
||||
Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•')))
|
||||
}
|
||||
|
||||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character.
|
||||
pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||
let first = c.chars().next().unwrap_or('•');
|
||||
if f.starts_with("webdings") {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Format Background pane: picture transparency, tiling, and Apply to All.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::{Background, Fill};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// 1×1 PNG.
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn harness(mut app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
app.ui.tab = "Design".into();
|
||||
app.ui.pane = Some("background".into());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn picture_mode(h: &Harness<'_, SlideApp>) -> PictureMode {
|
||||
match &h.state().session.active().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.mode.clone(),
|
||||
other => panic!("expected a picture background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_fill_shows_transparency_and_tiling() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()));
|
||||
assert!(h.query_all_by_label("Format Background").next().is_some(), "the pane is open");
|
||||
let _ = h.get_by_label("Picture or texture fill");
|
||||
assert!(h.query_all_by_label("Transparency").next().is_some());
|
||||
h.get_by_label("Tile picture as texture").click();
|
||||
h.run_steps(2);
|
||||
let _ = h.get_by_label("Offset X");
|
||||
let _ = h.get_by_label("Scale X");
|
||||
let _ = h.get_by_label("Alignment");
|
||||
let _ = h.get_by_label("Mirror");
|
||||
assert!(matches!(picture_mode(&h), PictureMode::Tile { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_fill_shows_transparency_without_tile_options() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"color": "accent1"})).unwrap();
|
||||
let h = harness(SlideApp::new(s, Services::default()));
|
||||
assert!(h.query_all_by_label("Transparency").next().is_some());
|
||||
let _ = h.get_by_label("Solid fill");
|
||||
assert!(h.query_by_label("Tile picture as texture").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_all_copies_the_picture() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()));
|
||||
h.get_by_label("Apply to All").click();
|
||||
h.run_steps(2);
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
assert!(matches!(doc.slides[1].background, Some(Background::Fill { fill: Fill::Picture(_) })));
|
||||
assert_eq!(doc.slides[1].background, doc.slides[0].background);
|
||||
}
|
||||
@@ -398,6 +398,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text));
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
// Errors raised on a backstage page (a missing recent file, a failed open) would
|
||||
// otherwise vanish: the status bar only draws once the backstage is closed (#172).
|
||||
// A real label, not painted text, so screen readers see it too.
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
ui.label(RichText::new(&msg).font(theme::font(12.0)).color(t.accent));
|
||||
ui.add_space(8.0);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
match bs.page {
|
||||
Page::Home => home(app, ui, &mut bs, &mut out),
|
||||
Page::New => new_page(app, ui, &mut bs),
|
||||
@@ -583,6 +591,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
}
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
for path in list {
|
||||
let missing = !app.recent_exists(&path);
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
@@ -590,10 +599,36 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, t.text, false);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), t.text);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim);
|
||||
if resp.on_hover_text(&path).clicked()
|
||||
let ink = if missing { t.text_dim } else { t.text };
|
||||
// The row is as wide as the page's content, which a narrow window clips: keep the mark
|
||||
// and the remove button inside the visible part of the row.
|
||||
let visible_right = rect.right().min(ui.clip_rect().right() - 6.0);
|
||||
{
|
||||
let p = ui.painter();
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim);
|
||||
// A missing file says so before it is clicked (#172).
|
||||
if missing {
|
||||
p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim);
|
||||
}
|
||||
}
|
||||
let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) };
|
||||
// Remove from the list: a real labelled widget on top of the row, with a code-drawn icon
|
||||
// (a text glyph would need a font that has it).
|
||||
let x = ui.allocate_rect(
|
||||
Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)),
|
||||
Sense::click(),
|
||||
);
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent"));
|
||||
if x.hovered() {
|
||||
ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false);
|
||||
if x.on_hover_text("Remove from Recent").clicked() {
|
||||
app.forget_recent(&path);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Err(e) = app.open_path(&path)
|
||||
{
|
||||
app.notify(e);
|
||||
|
||||
+135
-41
@@ -153,10 +153,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
offset.x = offset.x.clamp(0.0, overflow.x);
|
||||
offset.y = offset.y.clamp(0.0, overflow.y);
|
||||
let slide_rect = content_rect.translate(-offset);
|
||||
// On the physical pixel grid, so the slide texture is one texel per device pixel (#184). The
|
||||
// zoom anchor keeps the unsnapped rect, so snapping never accumulates into drift.
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let exact_rect = content_rect.translate(-offset);
|
||||
let slide_rect = crate::textures::device_pixel_rect(exact_rect, ppp);
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(scroll_id, offset);
|
||||
d.insert_temp(last_id, (slide_rect, scale));
|
||||
d.insert_temp(last_id, (exact_rect, scale));
|
||||
});
|
||||
app.slide_rect = Some(slide_rect);
|
||||
app.canvas_scale = scale;
|
||||
@@ -164,7 +168,6 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let painter = ui.painter_at(avail);
|
||||
// Shadow + slide.
|
||||
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
|
||||
let now = ui.input(|i| i.time);
|
||||
let gifs = match app.session.active() {
|
||||
@@ -425,7 +428,7 @@ fn master_tex(
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
@@ -760,6 +763,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
},
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
@@ -768,7 +773,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
egui::CursorIcon::Move
|
||||
}
|
||||
}
|
||||
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
|
||||
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
|
||||
Some(_) => egui::CursorIcon::ResizeVertical,
|
||||
@@ -887,21 +892,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
}
|
||||
}
|
||||
}
|
||||
// Caret / text selection.
|
||||
if let Some(ts) = &editing
|
||||
&& !ts.notes
|
||||
&& ts.cell.is_none()
|
||||
&& let Some(sh) = st.shape(ts.shape)
|
||||
&& let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts)
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
// Where the text is drawn: flips don't mirror it, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let (s0, s1) = ts.ordered();
|
||||
for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) {
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
||||
// Caret / text selection, in a shape or a table cell.
|
||||
if let Some(ts) = &editing {
|
||||
for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) {
|
||||
painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE));
|
||||
}
|
||||
// An input method's composition: underlined in place, thicker under the part being converted.
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
@@ -916,30 +910,15 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
}
|
||||
}
|
||||
}
|
||||
// Table cell editing: outline the cell.
|
||||
// Table cell editing: outline the cell (merged cells as drawn).
|
||||
if let Some(ts) = &editing
|
||||
&& let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape))
|
||||
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
|
||||
&& let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh)
|
||||
&& let Some(cr) = f.cell_rect(r, c)
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let heights = deckcraft_engine::tools::table_rows(st, tb);
|
||||
let y0: f64 = heights.iter().take(r).sum();
|
||||
let x0: f64 = tb.cols.iter().take(c).sum();
|
||||
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
|
||||
let a = x.affine();
|
||||
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
|
||||
let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)]
|
||||
.map(|q| xf.to_screen(f.affine * q));
|
||||
poly(painter, pts, Stroke::new(2.0, t.accent));
|
||||
// The composition is in the cell text; underline it where that text is drawn (#101).
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
||||
}
|
||||
if !ts.is_range() {
|
||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Smart guides, marquee, previews.
|
||||
for g in &app.session.tool.guides {
|
||||
@@ -1029,6 +1008,24 @@ fn reported_pressure(ui: &Ui) -> Option<f32> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The text selection (quads) of the text being edited, in slide space: in a shape or a table
|
||||
/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`.
|
||||
pub(crate) struct TextMarks {
|
||||
pub selection: Vec<[Point; 4]>,
|
||||
}
|
||||
|
||||
pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option<TextMarks> {
|
||||
let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?;
|
||||
let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 };
|
||||
let (s0, s1) = ts.ordered();
|
||||
let selection = l
|
||||
.selection_rects(pos(s0), pos(s1))
|
||||
.into_iter()
|
||||
.map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q))
|
||||
.collect();
|
||||
Some(TextMarks { selection })
|
||||
}
|
||||
|
||||
fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) {
|
||||
p.circle_filled(c, 6.0, t.handle_fill);
|
||||
p.add(egui::Shape::Path(egui::epaint::PathShape {
|
||||
@@ -1239,6 +1236,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
let _ = app.run("shape.lock", json!({"locked": !locked}));
|
||||
ui.close();
|
||||
}
|
||||
let picture = app
|
||||
.session
|
||||
.active()
|
||||
.is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })));
|
||||
if picture && ui.button("Change Picture…").clicked() {
|
||||
let _ = app.run("app.changePictureDialog", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Link…").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
||||
ui.close();
|
||||
@@ -1346,6 +1351,7 @@ mod tests {
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
@@ -1373,8 +1379,10 @@ mod tests {
|
||||
h.run_steps(30);
|
||||
}
|
||||
|
||||
/// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel
|
||||
/// grid (#184).
|
||||
fn assert_near(a: Pos2, b: Pos2, what: &str) {
|
||||
assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}");
|
||||
assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}");
|
||||
}
|
||||
|
||||
/// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer.
|
||||
@@ -1430,6 +1438,92 @@ mod tests {
|
||||
assert_near(mid, slide_point(&h, c), "zoom out");
|
||||
}
|
||||
|
||||
fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let data = json!([["Hello world", "b"], ["c", ""]]);
|
||||
let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(s, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
harness.run_steps(2);
|
||||
(harness, id)
|
||||
}
|
||||
|
||||
/// Screen position of slide point (x, y).
|
||||
fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 {
|
||||
Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y))
|
||||
}
|
||||
|
||||
/// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click).
|
||||
fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
let t = harness.ctx.input(|i| i.time) + 0.05;
|
||||
harness.input_mut().time = Some(t);
|
||||
harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
|
||||
#[test]
|
||||
fn double_click_in_a_table_cell_shows_the_caret_there() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
// The empty cell (1, 1) spans x 300..500, y 140..180.
|
||||
let p = screen(harness.state(), 400.0, 160.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
click(&mut harness, p);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().expect("editing the cell");
|
||||
assert_eq!((ts.shape, ts.cell), (id, Some((1, 1))));
|
||||
assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range");
|
||||
let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret");
|
||||
for q in [top, bottom] {
|
||||
assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)");
|
||||
}
|
||||
// A click in another cell moves the caret there.
|
||||
let p = screen(harness.state(), 110.0, 120.0);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(1);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0)));
|
||||
let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap();
|
||||
assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)");
|
||||
}
|
||||
|
||||
/// Table borders show a resize cursor and drag to resize columns.
|
||||
#[test]
|
||||
fn table_borders_resize_with_the_pointer() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
let p = screen(harness.state(), 300.0, 120.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
harness.step();
|
||||
assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn);
|
||||
let q = screen(harness.state(), 340.0, 120.0);
|
||||
harness.drag_at(p);
|
||||
harness.step();
|
||||
for k in 1..=4 {
|
||||
harness.hover_at(p + (q - p) * k as f32 / 4.0);
|
||||
harness.step();
|
||||
}
|
||||
harness.drop_at(q);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") };
|
||||
assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_follows_spacing_and_rejects_unusable_values() {
|
||||
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
|
||||
|
||||
@@ -1521,6 +1521,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("JPEG image (current slide)", "jpeg"),
|
||||
("PowerPoint Presentation (.pptx)", "pptx"),
|
||||
("Outline (.txt)", "outline"),
|
||||
("Animated GIF (.gif)", "gif"),
|
||||
] {
|
||||
if ui.radio(fmt == f, l).clicked() {
|
||||
d.fields.insert("fmt".into(), f.into());
|
||||
@@ -1564,6 +1565,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("pdfHidden".into(), hidden.to_string());
|
||||
}
|
||||
}
|
||||
let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) };
|
||||
if let Err(e) = &gif_params {
|
||||
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
let pdf_params = {
|
||||
let (layout, per) = match pdf_layout.as_str() {
|
||||
"notes" => ("notes", 0),
|
||||
@@ -1574,7 +1579,19 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
};
|
||||
let (ok, cancel) = buttons(ui, "Export");
|
||||
if ok {
|
||||
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
|
||||
let gif_params = match gif_params {
|
||||
Ok(v) => v,
|
||||
// Keep the dialog open so the range or width can be corrected.
|
||||
Err(e) => {
|
||||
app.set_status(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let extra = match fmt.as_str() {
|
||||
"pdf" => pdf_params,
|
||||
"gif" => gif_params,
|
||||
_ => json!({}),
|
||||
};
|
||||
app.export_as(&fmt, extra);
|
||||
}
|
||||
ok || cancel
|
||||
@@ -3205,6 +3222,64 @@ mod size_position_tests;
|
||||
#[path = "photo_album_tests.rs"]
|
||||
mod photo_album_tests;
|
||||
|
||||
/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden
|
||||
/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message.
|
||||
fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result<Value, String> {
|
||||
use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS};
|
||||
let n = app.session.active().map_or(0, |s| s.doc.slides.len());
|
||||
let size = d.get("gifSize", "medium");
|
||||
let size_label = |k: &str| match k {
|
||||
"small" => "Small (640 px wide)",
|
||||
"large" => "Large (1280 px wide)",
|
||||
"extraLarge" => "Extra Large (1920 px wide)",
|
||||
"custom" => "Custom width",
|
||||
_ => "Medium (960 px wide)",
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:"));
|
||||
egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| {
|
||||
for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) {
|
||||
if ui.selectable_label(size == k, size_label(k)).clicked() {
|
||||
d.fields.insert("gifSize".into(), k.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if size == "custom" {
|
||||
field(ui, d, "Width (pixels):", "gifWidth", "960");
|
||||
}
|
||||
field(ui, d, "Seconds per slide:", "gifSeconds", "5");
|
||||
let n_text = n.max(1).to_string();
|
||||
field(ui, d, "From slide:", "gifFrom", "1");
|
||||
field(ui, d, "To slide:", "gifTo", &n_text);
|
||||
let forever = check(ui, d, "Loop forever", "gifLoop", true);
|
||||
let hidden = check(ui, d, "Include hidden slides", "gifHidden", false);
|
||||
|
||||
let width = if size == "custom" {
|
||||
let w = num(d, "gifWidth", 960.0).round();
|
||||
if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) {
|
||||
return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels"));
|
||||
}
|
||||
json!(w as u32)
|
||||
} else {
|
||||
json!(size)
|
||||
};
|
||||
let seconds = num(d, "gifSeconds", f64::NAN);
|
||||
if deckcraft_engine::gif_export::check_seconds(seconds).is_err() {
|
||||
return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}"));
|
||||
}
|
||||
let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden});
|
||||
let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round());
|
||||
if !(from >= 1.0 && from <= to && to <= n as f64) {
|
||||
return Err(format!("Slides: choose a range from 1 to {n}"));
|
||||
}
|
||||
if from > 1.0 || to < n as f64 {
|
||||
p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "theme_colors_tests.rs"]
|
||||
mod theme_colors_tests;
|
||||
@@ -3247,6 +3322,68 @@ mod tests {
|
||||
h
|
||||
}
|
||||
|
||||
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
|
||||
#[test]
|
||||
fn export_dialog_writes_an_animated_gif() {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.gif");
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let suggested = std::rc::Rc::new(RefCell::new(String::new()));
|
||||
let seen = suggested.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(move |s: &str| {
|
||||
*seen.borrow_mut() = s.to_string();
|
||||
Some(picked.clone())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, services),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.exportDialog", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Animated GIF (.gif)").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif"));
|
||||
harness.get_by_label("Seconds per slide:");
|
||||
harness.get_by_label("Loop forever").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
// A range outside the deck keeps the dialog open with a message, and nothing is written.
|
||||
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into());
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status);
|
||||
assert!(!path.exists());
|
||||
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into());
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "the dialog closes after exporting");
|
||||
assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_link_opens_as_a_new_web_address() {
|
||||
let seed = seed_from(CurrentLink::None, &[]);
|
||||
|
||||
@@ -132,14 +132,14 @@ fn korean_composes_in_the_notes_pane() {
|
||||
for pre in ["ㄴ", "노", "놑", "노트"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
|
||||
assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one");
|
||||
}
|
||||
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, "노트");
|
||||
assert_eq!(h.state().notes_buf.2.text, "노트");
|
||||
// Focus leaves the field (Escape): the notes are committed as one step.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
|
||||
+303
-2
@@ -18,6 +18,7 @@ pub mod fillui;
|
||||
pub mod icons;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod notes;
|
||||
pub mod panes;
|
||||
mod preview;
|
||||
pub mod qat;
|
||||
@@ -35,6 +36,7 @@ pub mod toast;
|
||||
pub mod uifonts;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use deckcraft_engine::{Mods, Session, Target, UiRequest};
|
||||
@@ -83,6 +85,8 @@ enum FilePurpose {
|
||||
Picture,
|
||||
Audio,
|
||||
Video,
|
||||
/// Change Picture: the picture replaces the selected one (`picture.change`).
|
||||
ChangePicture,
|
||||
/// A drop, or any other purpose: decide from the name and bytes.
|
||||
Infer,
|
||||
}
|
||||
@@ -182,12 +186,16 @@ fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'stati
|
||||
FilePurpose::Picture => Some("insert.picture"),
|
||||
FilePurpose::Audio => Some("insert.audio"),
|
||||
FilePurpose::Video => Some("insert.video"),
|
||||
FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"),
|
||||
FilePurpose::Open => open_command(name),
|
||||
FilePurpose::Infer => infer_command(name, bytes),
|
||||
};
|
||||
cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file"))
|
||||
}
|
||||
|
||||
/// How long a transient status message stays visible.
|
||||
const STATUS_LINGER_MS: f64 = 6000.0;
|
||||
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
@@ -389,6 +397,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.openDialog", "Open…", None, "{purpose?}"),
|
||||
("app.insertPictureDialog", "Picture from File…", None, "{}"),
|
||||
("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"),
|
||||
("app.changePictureDialog", "Change Picture…", None, "{}"),
|
||||
("app.insertAudioDialog", "Audio from File…", None, "{}"),
|
||||
("app.insertVideoDialog", "Video from File…", None, "{}"),
|
||||
("app.saveAsDialog", "Save As…", None, "{}"),
|
||||
@@ -476,7 +485,7 @@ pub struct SlideApp {
|
||||
pub last_recovery: f64,
|
||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||
pub quit_confirmed: bool,
|
||||
pub(crate) notes_buf: (u64, usize, String),
|
||||
pub(crate) notes_buf: (u64, usize, notes::Draft),
|
||||
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
@@ -489,6 +498,9 @@ pub struct SlideApp {
|
||||
pub sorter: sorter::State,
|
||||
/// The slide thumbnail pane was the last thing clicked, so ⌘A selects slides, not shapes.
|
||||
pub(crate) slides_focused: bool,
|
||||
/// Which recent paths exist on disk, probed once per path per session (a dead network share
|
||||
/// must not stall every frame). Not persisted.
|
||||
recent_checked: HashMap<String, bool>,
|
||||
/// A trackpad twist is in progress (one undo step when it ends).
|
||||
twisting: bool,
|
||||
/// Next cancellation identity for a browser pick or a dropped file.
|
||||
@@ -549,13 +561,14 @@ impl SlideApp {
|
||||
last_time: 0.0,
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
notes_buf: (0, usize::MAX, notes::Draft::default()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
doc_window: None,
|
||||
sorter: sorter::State::default(),
|
||||
slides_focused: false,
|
||||
recent_checked: HashMap::new(),
|
||||
twisting: false,
|
||||
next_file_id: 1,
|
||||
pending_files: Vec::new(),
|
||||
@@ -577,6 +590,56 @@ impl SlideApp {
|
||||
self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() });
|
||||
}
|
||||
|
||||
/// The transient status message while it is fresh; clears it once stale. Both the status bar
|
||||
/// and the start screen draw it — the start screen has no status bar, and errors raised there
|
||||
/// (a missing recent file, a failed sample) used to vanish (issue #172).
|
||||
pub fn fresh_status(&mut self) -> Option<String> {
|
||||
let (msg, at) = self.status.clone()?;
|
||||
if now_ms() - at < STATUS_LINGER_MS {
|
||||
Some(msg)
|
||||
} else {
|
||||
self.status = None;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this recent path exist on disk? Probed once per path per session and only on
|
||||
/// desktop (the web build has no file system to ask): a stat on a dead network share can
|
||||
/// block for seconds, so it must not repeat on every frame. A stale answer is harmless —
|
||||
/// opening still tries and reports, and a successful open re-probes.
|
||||
pub fn recent_exists(&mut self, path: &str) -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(ok) = self.recent_checked.get(path) {
|
||||
return *ok;
|
||||
}
|
||||
// A stat on a dead network location (UNC share, URL) can block for the OS timeout,
|
||||
// so remote-looking paths are never probed: they stay unmarked and are judged when
|
||||
// opened. A disconnected mapped drive would still stall once — accepted.
|
||||
if path.starts_with("\\\\") || path.contains("://") {
|
||||
return true;
|
||||
}
|
||||
let ok = std::path::Path::new(path).is_file();
|
||||
if self.recent_checked.len() > 64 {
|
||||
let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect();
|
||||
self.recent_checked.retain(|p, _| live.contains(p));
|
||||
}
|
||||
self.recent_checked.insert(path.to_string(), ok);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a path from the Recent list (its file is gone or the user asked for it).
|
||||
pub fn forget_recent(&mut self, path: &str) {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.recent_checked.remove(path);
|
||||
}
|
||||
|
||||
/// Run a command by id (UI commands here, everything else in the engine). Errors show in the
|
||||
/// status bar and are returned.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
@@ -671,6 +734,7 @@ impl SlideApp {
|
||||
"pptx" => ("pptx", "pptx", false),
|
||||
"outline" | "txt" => ("txt", "outline", false),
|
||||
"deckcraft" => ("deckcraft", "deckcraft", false),
|
||||
"gif" => ("gif", "gif", false),
|
||||
other => {
|
||||
self.set_status(format!("DeckCraft can't export “{other}”"));
|
||||
return false;
|
||||
@@ -700,6 +764,7 @@ impl SlideApp {
|
||||
Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) })
|
||||
}
|
||||
"pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra),
|
||||
"gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra),
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&doc, other),
|
||||
};
|
||||
self.download(&suggested, bytes)
|
||||
@@ -932,6 +997,7 @@ impl SlideApp {
|
||||
}
|
||||
self.pick_and_run("picture", "smartArt.picture", p);
|
||||
}
|
||||
"app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"),
|
||||
"app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"),
|
||||
"app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),
|
||||
"app.saveAsDialog" => self.save_as_dialog("deckcraft"),
|
||||
@@ -1104,6 +1170,8 @@ impl SlideApp {
|
||||
|
||||
pub fn open_path(&mut self, path: &str) -> Result<(), String> {
|
||||
self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
self.status = None; // an older error (a missing file just before this one) must not linger
|
||||
self.recent_checked.remove(path);
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(RECENT_MAX);
|
||||
@@ -1145,6 +1213,12 @@ impl SlideApp {
|
||||
}
|
||||
} else if self.services.open_async.is_some() {
|
||||
let id = self.begin_external_file(purpose);
|
||||
// Change Picture: the picture that arrives replaces the selected one (#115).
|
||||
if cmd == "picture.change"
|
||||
&& let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id)
|
||||
{
|
||||
r.purpose = FilePurpose::ChangePicture;
|
||||
}
|
||||
if let Some(f) = self.services.open_async.as_mut() {
|
||||
f(purpose, id);
|
||||
}
|
||||
@@ -1887,6 +1961,7 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
fn shape_rot(app: &SlideApp) -> f64 {
|
||||
let d = app.session.active().unwrap();
|
||||
@@ -2003,6 +2078,61 @@ mod tests {
|
||||
assert!(harness.state().toast.is_none());
|
||||
}
|
||||
|
||||
/// #115: Change Picture replaced nothing — it inserted a second picture.
|
||||
#[test]
|
||||
fn change_picture_replaces_the_selected_picture() {
|
||||
let svg = |c: &str| {
|
||||
format!("<svg xmlns='http://www.w3.org/2000/svg' width='4' height='4'><rect width='4' height='4' fill='{c}'/></svg>").into_bytes()
|
||||
};
|
||||
let media = |app: &SlideApp| {
|
||||
let d = app.session.active().unwrap();
|
||||
let pics: Vec<_> = d.doc.slides[0]
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None })
|
||||
.collect();
|
||||
assert_eq!(pics.len(), 1, "one picture, replaced in place");
|
||||
pics[0]
|
||||
};
|
||||
let insert = |app: &mut SlideApp| {
|
||||
let id =
|
||||
app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap()
|
||||
["id"]
|
||||
.clone();
|
||||
app.session.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
media(app)
|
||||
};
|
||||
|
||||
// Desktop: the file dialog returns a path.
|
||||
let blue = svg("blue");
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))),
|
||||
read: Some(Box::new(move |_: &str| Ok(blue.clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
let before = insert(&mut app);
|
||||
app.run("app.changePictureDialog", json!({})).unwrap();
|
||||
assert_ne!(media(&app), before);
|
||||
|
||||
// Web: the browser picker delivers the file through the inbox later.
|
||||
let inbox = Inbox::default();
|
||||
let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let record = started.clone();
|
||||
let services = Services {
|
||||
open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))),
|
||||
inbox: Some(inbox.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
let before = insert(&mut app);
|
||||
app.run("app.changePictureDialog", json!({})).unwrap();
|
||||
let id = started.lock().unwrap()[0];
|
||||
inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") }));
|
||||
app.drain_inbox();
|
||||
assert_ne!(media(&app), before);
|
||||
}
|
||||
|
||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
@@ -2479,6 +2609,177 @@ mod tests {
|
||||
assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes");
|
||||
}
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::new(), Services::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws
|
||||
/// the open error right there (the status bar is hidden while the backstage is open, so the
|
||||
/// message used to vanish).
|
||||
#[test]
|
||||
fn missing_recent_is_marked_and_its_error_shows() {
|
||||
let mut harness = harness();
|
||||
let missing = "/deckcraft-tests/172/missing.deckcraft";
|
||||
harness.state_mut().ui.recent = vec![missing.to_string()];
|
||||
harness.run_steps(2);
|
||||
// The row is labelled with the file's name. Clicking it raises the open error, and the
|
||||
// drawn message is a second node that mentions the name (the OS text after it varies).
|
||||
harness.get_by_label("missing.deckcraft").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_some(), "the open error was raised");
|
||||
assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry");
|
||||
}
|
||||
|
||||
/// #172: a Recent entry can be removed from the list.
|
||||
#[test]
|
||||
fn recent_entry_can_be_removed() {
|
||||
let mut harness = harness();
|
||||
harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()];
|
||||
harness.run_steps(2);
|
||||
let removes = harness.get_all_by_label("Remove from Recent").collect::<Vec<_>>();
|
||||
assert_eq!(removes.len(), 2);
|
||||
// The remove button must sit inside the visible part of the row even when the page's
|
||||
// content is wider than the window.
|
||||
assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect());
|
||||
removes[1].click();
|
||||
harness.run_steps(1);
|
||||
assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]);
|
||||
}
|
||||
|
||||
/// The existence probe tells present files from missing ones and never touches remote paths.
|
||||
#[test]
|
||||
fn recent_exists_tells_missing_from_present() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id()));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
assert!(app.recent_exists(&path.to_string_lossy()), "the file exists");
|
||||
assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft"));
|
||||
assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed");
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text.
|
||||
#[test]
|
||||
fn the_language_dialog_marks_the_selected_text() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap();
|
||||
harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap();
|
||||
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
||||
// The anchored window settles over its first frames; click once it stays put.
|
||||
harness.run_steps(4);
|
||||
harness.get_by_label("Français (France)").click();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("OK").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none());
|
||||
let d = harness.state().session.active().unwrap();
|
||||
let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
|
||||
}
|
||||
|
||||
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
|
||||
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
|
||||
#[test]
|
||||
fn editing_notes_in_the_pane_keeps_their_formatting() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
let point = |runs: Vec<Run>| Paragraph {
|
||||
level: 1,
|
||||
props: ParaProps {
|
||||
margin_left: Some(22.5),
|
||||
indent: Some(-22.5),
|
||||
bullet: Some(Bullet::Char { char: "§".into() }),
|
||||
bullet_font: Some("Wingdings".into()),
|
||||
..Default::default()
|
||||
},
|
||||
runs,
|
||||
..Default::default()
|
||||
};
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
let notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
|
||||
Paragraph::new("Žiaci pracujú vo dvojiciach."),
|
||||
point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]),
|
||||
point(vec![Run::new("Reflexia")]),
|
||||
Paragraph::new("Pomôcky: počítač"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::with_new();
|
||||
session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| {
|
||||
let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at))));
|
||||
egui::TextEdit::store_state(&h.ctx, id, st);
|
||||
};
|
||||
let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap();
|
||||
let text = notes.text();
|
||||
harness.ctx.memory_mut(|m| m.request_focus(id));
|
||||
harness.run_steps(1);
|
||||
// Type into the bold heading.
|
||||
caret(&mut harness, chars(&text, "prehľad"));
|
||||
harness.run_steps(1);
|
||||
harness.event(egui::Event::Text(" hodiny".into()));
|
||||
harness.run_steps(1);
|
||||
// Enter after a bulleted point, and type the next one.
|
||||
let text = text.replacen("prehľad", "prehľad hodiny", 1);
|
||||
caret(&mut harness, chars(&text, "cyklus"));
|
||||
harness.run_steps(1);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.event(egui::Event::Text("Nový bod".into()));
|
||||
harness.run_steps(1);
|
||||
// Leave the field: the notes are saved.
|
||||
harness.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
harness.run_steps(2);
|
||||
let check = |n: &TextBody| {
|
||||
assert_eq!(
|
||||
n.text(),
|
||||
"Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač"
|
||||
);
|
||||
assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}");
|
||||
assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(n.paragraphs[1].props, ParaProps::default());
|
||||
assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label");
|
||||
assert_eq!(n.paragraphs[2].runs[1].props.bold, None);
|
||||
for i in [2, 3, 4] {
|
||||
assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}");
|
||||
}
|
||||
assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default()));
|
||||
};
|
||||
check(&harness.state().session.active().unwrap().doc.slides[0].notes);
|
||||
// Saved as .pptx and reopened.
|
||||
let s = &mut harness.state_mut().session;
|
||||
let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap();
|
||||
check(&s.active().unwrap().doc.slides[0].notes);
|
||||
}
|
||||
|
||||
fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and
|
||||
//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs,
|
||||
//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is
|
||||
//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest
|
||||
//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit;
|
||||
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
|
||||
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
|
||||
use egui::{Align2, Color32, Stroke, Ui, vec2};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Size and colour of a notes editor's text.
|
||||
pub struct Look {
|
||||
/// Pixels for 12 pt text, the notes' default size; indents scale with it.
|
||||
pub size: f32,
|
||||
/// `None` = the widget text colour.
|
||||
pub color: Option<Color32>,
|
||||
}
|
||||
|
||||
/// Notes being edited: the text in the editor and the formatted notes it stands for.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub text: String,
|
||||
pub body: TextBody,
|
||||
/// Edited since it was saved.
|
||||
pub edited: bool,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
pub fn new(body: &TextBody) -> Self {
|
||||
Draft { text: body.text(), body: body.clone(), edited: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't
|
||||
/// be mapped onto it.
|
||||
fn typed(body: &TextBody, text: &str) -> Option<TextBody> {
|
||||
let mut b = body.clone();
|
||||
edit::replace_text(&mut b, text);
|
||||
(b.text() == text).then_some(b)
|
||||
}
|
||||
|
||||
/// A length in points from the file as pixels, bounded.
|
||||
fn px(pt: f64, k: f32) -> f32 {
|
||||
if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Where a paragraph's first line starts and its other lines start (points): a level without its
|
||||
/// own margin is indented 36 pt per level, as in the default notes master.
|
||||
fn indents(p: &Paragraph) -> (f64, f64) {
|
||||
let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8)));
|
||||
let first = margin + p.props.indent.unwrap_or(0.0);
|
||||
(first, margin)
|
||||
}
|
||||
|
||||
/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an
|
||||
/// unnumbered paragraph at the same or a lower level).
|
||||
fn bullets(body: &TextBody) -> Vec<Option<String>> {
|
||||
let mut numbering = [0u32; 9];
|
||||
body.paragraphs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let lvl = usize::from(p.level.min(8));
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. }));
|
||||
for (i, n) in numbering.iter_mut().enumerate().skip(lvl) {
|
||||
if i > lvl || !auto {
|
||||
*n = 0;
|
||||
}
|
||||
}
|
||||
match p.props.bullet.as_ref()? {
|
||||
Bullet::None => None,
|
||||
Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())),
|
||||
Bullet::AutoNum { scheme, start_at } => {
|
||||
let n = numbering.get_mut(lvl)?;
|
||||
*n = n.saturating_add(1);
|
||||
Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n)))
|
||||
}
|
||||
Bullet::Picture { .. } => Some("•".into()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat {
|
||||
let size = match props.size {
|
||||
Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0),
|
||||
_ => size,
|
||||
};
|
||||
let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) };
|
||||
let mut f = TextFormat::simple(font, color);
|
||||
f.italics = props.italic == Some(true);
|
||||
if props.underline.as_deref().is_some_and(|u| u != "none") {
|
||||
f.underline = Stroke::new(1.0, color);
|
||||
}
|
||||
if matches!(props.strike, Some(Strike::Single | Strike::Double)) {
|
||||
f.strikethrough = Stroke::new(1.0, color);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at
|
||||
/// `text`, clear of a bullet.
|
||||
struct ParaLayout {
|
||||
job: LayoutJob,
|
||||
margin: f32,
|
||||
/// x of the first line's text.
|
||||
text: f32,
|
||||
}
|
||||
|
||||
fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout {
|
||||
let k = size / 12.0;
|
||||
let (first, margin) = indents(p);
|
||||
let (first, margin) = (px(first, k), px(margin, k));
|
||||
let text = match bullet {
|
||||
// The text starts at the margin, after the bullet if it's wider than the hanging indent.
|
||||
Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)),
|
||||
None => first,
|
||||
};
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = (wrap - margin).max(size * 4.0);
|
||||
let mut lead = (text - margin).max(0.0);
|
||||
for r in &p.runs {
|
||||
let t = r.content();
|
||||
if !t.is_empty() {
|
||||
job.append(t, lead, format(&r.props, size, color));
|
||||
lead = 0.0;
|
||||
}
|
||||
}
|
||||
if job.text.is_empty() {
|
||||
job.append("", 0.0, format(&p.end_props, size, color));
|
||||
}
|
||||
ParaLayout { job, margin, text }
|
||||
}
|
||||
|
||||
/// The whole notes as one job (the galley's text and formats).
|
||||
fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob {
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap;
|
||||
for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() {
|
||||
if i > 0 {
|
||||
job.append("\n", 0.0, TextFormat::simple(theme::font(size), color));
|
||||
}
|
||||
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
|
||||
for s in pl.job.sections {
|
||||
if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) {
|
||||
job.append(t, s.leading_space, s.format);
|
||||
}
|
||||
}
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up
|
||||
/// under the text and not under the bullet.
|
||||
fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc<Galley> {
|
||||
let whole = job(body, size, color, wrap);
|
||||
if body.paragraphs.is_empty() {
|
||||
return ui.fonts_mut(|f| f.layout_job(whole));
|
||||
}
|
||||
let parts: Vec<Arc<Galley>> = ui.fonts_mut(|f| {
|
||||
body.paragraphs
|
||||
.iter()
|
||||
.zip(bullets(body))
|
||||
.map(|(p, bullet)| {
|
||||
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
|
||||
let mut g = (*f.layout_job(pl.job)).clone();
|
||||
for (i, row) in g.rows.iter_mut().enumerate() {
|
||||
// The first line already holds the gap after a bullet; a first line hanging
|
||||
// left of the margin without one starts there.
|
||||
row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin };
|
||||
}
|
||||
Arc::new(g)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point()))
|
||||
}
|
||||
|
||||
/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint,
|
||||
/// size and frame. Edits update the draft's text and formatted body. Returns the field's output
|
||||
/// (galley and caret too, for spelling marks).
|
||||
pub fn editor(
|
||||
ui: &mut Ui,
|
||||
draft: &mut Draft,
|
||||
look: &Look,
|
||||
configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>,
|
||||
) -> egui::text_edit::TextEditOutput {
|
||||
let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()));
|
||||
let size = look.size;
|
||||
let before = draft.body.clone();
|
||||
let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) {
|
||||
Some(b) => galley(ui, &b, size, color, wrap),
|
||||
None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))),
|
||||
};
|
||||
let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui);
|
||||
if draft.body.text() != draft.text {
|
||||
edit::replace_text(&mut draft.body, &draft.text);
|
||||
draft.edited = true;
|
||||
}
|
||||
// Bullets and numbers sit at the start of each paragraph's first line.
|
||||
if draft.body.text() == draft.text {
|
||||
let painter = ui.painter().with_clip_rect(out.text_clip_rect);
|
||||
let k = size / 12.0;
|
||||
let mut at = 0usize;
|
||||
for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) {
|
||||
if let Some(b) = bullet {
|
||||
let row = out.galley.pos_from_cursor(CCursor::new(at));
|
||||
let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y);
|
||||
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
|
||||
}
|
||||
at += p.char_len() + 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_model::text::{ParaProps, Run};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Notes as PowerPoint users write them: a bold heading, a plain line, points with a
|
||||
/// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points,
|
||||
/// a point with an explicit buNone, closing plain lines; Slovak text throughout.
|
||||
fn notes() -> TextBody {
|
||||
let props = |bullet: Bullet| ParaProps {
|
||||
margin_left: Some(22.5),
|
||||
indent: Some(-22.5),
|
||||
bullet: Some(bullet),
|
||||
bullet_font: Some("Wingdings".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let point = |runs: Vec<Run>, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() };
|
||||
let square = || Bullet::Char { char: "§".into() };
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
|
||||
Paragraph::new("Žiaci pracujú vo dvojiciach."),
|
||||
point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()),
|
||||
point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()),
|
||||
point(vec![Run::new("Jeden")], num()),
|
||||
point(vec![Run::new("Dva")], num()),
|
||||
point(vec![Run::new("Bez odrážky")], Bullet::None),
|
||||
Paragraph::new("Pomôcky: počítač"),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_follow_the_paragraphs() {
|
||||
let b = bullets(¬es());
|
||||
assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_show_bold_runs_indents_and_wrap_under_the_text() {
|
||||
let body = notes();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut laid = None;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
|
||||
out.textures_delta.clear();
|
||||
let g = laid.unwrap();
|
||||
assert_eq!(g.job.text, body.text(), "the galley reads the notes");
|
||||
assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::<usize>(), body.text().chars().count());
|
||||
// Bold where the runs are bold, regular elsewhere.
|
||||
let font_of = |s: &str| {
|
||||
let b = g.job.text.find(s).unwrap();
|
||||
g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone()
|
||||
};
|
||||
assert_eq!(font_of("Metodický"), theme::bold(12.0));
|
||||
assert_eq!(font_of("Cieľ"), theme::bold(12.0));
|
||||
assert_eq!(font_of("vysvetliť"), theme::font(12.0));
|
||||
assert_eq!(font_of("Pomôcky"), theme::font(12.0));
|
||||
// Rows: x where each paragraph's text starts.
|
||||
let row_x = |s: &str| {
|
||||
let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap();
|
||||
g.pos_from_cursor(CCursor::new(at)).min.x
|
||||
};
|
||||
assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented");
|
||||
assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ"));
|
||||
assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0");
|
||||
// The long point wraps, and its wrapped lines start at the margin, not under the bullet.
|
||||
let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap();
|
||||
let first_row = g.pos_from_cursor(CCursor::new(long));
|
||||
let wrapped: Vec<_> =
|
||||
g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect();
|
||||
assert!(!wrapped.is_empty(), "the long point wraps");
|
||||
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_keeps_the_formatting_around_it() {
|
||||
let mut d = Draft::new(¬es());
|
||||
// Typing into the bold heading continues it in bold.
|
||||
let t = d.text.replacen("prehľad", "prehľad hodiny", 1);
|
||||
d.body = typed(&d.body, &t).unwrap();
|
||||
assert_eq!(d.body.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter at the end of a bulleted point starts another one with the same bullet and indent.
|
||||
let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1);
|
||||
d.body = typed(&d.body, &t).unwrap();
|
||||
let new = &d.body.paragraphs[3];
|
||||
assert_eq!(new.text(), "nový bod");
|
||||
assert_eq!((new.level, &new.props), (1, ¬es().paragraphs[2].props));
|
||||
assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold");
|
||||
// Hostile sizes and indents stay bounded.
|
||||
let mut wild = notes();
|
||||
wild.paragraphs[2].props.margin_left = Some(f64::NAN);
|
||||
wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY);
|
||||
let j = job(&wild, 12.0, Color32::BLACK, 300.0);
|
||||
assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0));
|
||||
}
|
||||
}
|
||||
+105
-10
@@ -784,6 +784,10 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
let mut tr = ((1.0 - color.alpha()) * 100.0) as f32;
|
||||
if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() {
|
||||
run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)}));
|
||||
}
|
||||
}
|
||||
Some(Fill::Gradient(g)) => {
|
||||
current = Some(json!({"gradient": crate::fillui::gradient_json(g)}));
|
||||
@@ -813,7 +817,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Fill::Picture(_)) => {
|
||||
Some(Fill::Picture(pf)) => {
|
||||
if ui.button("Insert picture from file…").clicked()
|
||||
&& let Some(pick) = app.services.pick_open.as_mut()
|
||||
&& let Some(path) = pick("picture")
|
||||
@@ -821,6 +825,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)}));
|
||||
}
|
||||
picture_background_options(app, ui, pf);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -831,12 +836,15 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "design.hideBackgroundGraphics", json!({"hide": h}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let picture = matches!(fill, Some(Fill::Picture(_)));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked()
|
||||
&& let Some(Value::Object(mut o)) = current.clone()
|
||||
{
|
||||
o.insert("all".into(), json!(true));
|
||||
run(app, "design.background", Value::Object(o));
|
||||
if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() {
|
||||
if let Some(Value::Object(mut o)) = current.clone() {
|
||||
o.insert("all".into(), json!(true));
|
||||
run(app, "design.background", Value::Object(o));
|
||||
} else if picture {
|
||||
run(app, "design.background", json!({"copy": true}));
|
||||
}
|
||||
}
|
||||
if ui.button("Reset Background").clicked() {
|
||||
run(app, "design.background", json!({"reset": true}));
|
||||
@@ -844,6 +852,86 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
let alpha = pf.alpha.unwrap_or(1.0);
|
||||
let mut tr = ((1.0 - alpha) * 100.0) as f32;
|
||||
if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() {
|
||||
run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)}));
|
||||
}
|
||||
let mut tile = matches!(pf.mode, PictureMode::Tile { .. });
|
||||
if ui.checkbox(&mut tile, "Tile picture as texture").changed() {
|
||||
run(app, "design.background", json!({"pictureProps": {"tile": tile}}));
|
||||
}
|
||||
let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return };
|
||||
for (label, id, value, suffix, scale) in [
|
||||
("Offset X", "bg-ox", *tx, " pt", 1.0),
|
||||
("Offset Y", "bg-oy", *ty, " pt", 1.0),
|
||||
("Scale X", "bg-sx", *sx * 100.0, "%", 0.01),
|
||||
("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01),
|
||||
] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label));
|
||||
if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) {
|
||||
let key = match id {
|
||||
"bg-ox" => "offsetX",
|
||||
"bg-oy" => "offsetY",
|
||||
"bg-sx" => "scaleX",
|
||||
_ => "scaleY",
|
||||
};
|
||||
run(app, "design.background", json!({"pictureProps": {key: v * scale}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
let align_name = match align.as_str() {
|
||||
"t" => "Top",
|
||||
"tr" => "Top Right",
|
||||
"l" => "Left",
|
||||
"ctr" => "Center",
|
||||
"r" => "Right",
|
||||
"bl" => "Bottom Left",
|
||||
"b" => "Bottom",
|
||||
"br" => "Bottom Right",
|
||||
_ => "Top Left",
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment"));
|
||||
egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| {
|
||||
for (k, l) in [
|
||||
("tl", "Top Left"),
|
||||
("t", "Top"),
|
||||
("tr", "Top Right"),
|
||||
("l", "Left"),
|
||||
("ctr", "Center"),
|
||||
("r", "Right"),
|
||||
("bl", "Bottom Left"),
|
||||
("b", "Bottom"),
|
||||
("br", "Bottom Right"),
|
||||
] {
|
||||
if ui.selectable_label(align == k, l).clicked() {
|
||||
run(app, "design.background", json!({"pictureProps": {"align": k}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let mirror_name = match flip.as_str() {
|
||||
"x" => "Horizontal",
|
||||
"y" => "Vertical",
|
||||
"xy" => "Both",
|
||||
_ => "None",
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror"));
|
||||
egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| {
|
||||
for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] {
|
||||
if ui.selectable_label(flip == k, l).clicked() {
|
||||
run(app, "design.background", json!({"pictureProps": {"mirror": k}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
@@ -1176,7 +1264,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let key_doc = (st.uid, st.revision);
|
||||
let w = ui.available_width() - 8.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = (w * ppp).max(16.0) as u32;
|
||||
let px = (w * ppp).round().max(16.0) as u32;
|
||||
let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx);
|
||||
let themes = deckcraft_model::theme::builtin_themes();
|
||||
let cache_id = egui::Id::new("design-idea-thumbs");
|
||||
@@ -1194,7 +1282,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
idx,
|
||||
&deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() },
|
||||
);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false));
|
||||
let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false));
|
||||
cache.tex.push((name, tex, px, h));
|
||||
};
|
||||
for (id, _) in &ideas {
|
||||
@@ -1215,9 +1303,12 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool {
|
||||
let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click());
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
// On the physical pixel grid, one texel per device pixel (#184).
|
||||
let at = crate::textures::device_pixel_rect(r, ppp);
|
||||
let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp));
|
||||
ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
at,
|
||||
CornerRadius::same(2),
|
||||
Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }),
|
||||
egui::StrokeKind::Outside,
|
||||
@@ -1275,6 +1366,10 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec
|
||||
#[path = "smartart_tests.rs"]
|
||||
mod smartart_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "background_tests.rs"]
|
||||
mod background_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
@@ -395,8 +395,9 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
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 tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false));
|
||||
let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point());
|
||||
ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(
|
||||
@@ -2638,7 +2639,7 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
rows(ui, |ui| {
|
||||
small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({}));
|
||||
small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({}));
|
||||
small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@ use deckcraft_model::{Presentation, SlideId};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, widgets};
|
||||
|
||||
/// Sorter-only state. Nothing here is part of the document.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -200,10 +200,10 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
||||
let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag());
|
||||
let label = format!("Slide {}", i + 1);
|
||||
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
|
||||
let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th));
|
||||
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, (g.tw * ppp) as u32, budget) {
|
||||
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);
|
||||
}
|
||||
@@ -375,8 +375,8 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
Rect::from_min_max(page.min + vec2(x0 as f32 * k, y0 as f32 * k), page.min + vec2(x1 as f32 * k, y1 as f32 * k))
|
||||
};
|
||||
let (slide_box, notes_box) = deckcraft_engine::pdf::notes_frames(&doc, paper);
|
||||
let srect = to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1));
|
||||
let sw = srect.width();
|
||||
// On the physical pixel grid, so the thumbnail is one texel per device pixel (#184).
|
||||
let srect = crate::textures::device_pixel_rect(to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1)), ui.ctx().pixels_per_point());
|
||||
if let Some(m) = doc.notes_master.as_deref() {
|
||||
// Laid out by the text engine as on the PDF page: wrapped, each run in its size and colour.
|
||||
for piece in deckcraft_engine::pdf::master_text_pieces(&doc, m, paper, idx + 1) {
|
||||
@@ -388,29 +388,41 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 2;
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) {
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
|
||||
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
|
||||
let notes_rect = to_screen((notes_box.x0, notes_box.y0, notes_box.x1, notes_box.y1));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
|
||||
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
|
||||
// A draft belongs to this document and to the notes it started from (#413): another document,
|
||||
// a reorder or an undo that changes them drops it.
|
||||
let id = egui::Id::new(("notespage", st.uid, idx));
|
||||
let stored: Option<(String, String)> = child.data_mut(|d| d.get_temp(id));
|
||||
let mut buf = widgets::text_draft(stored, ¤t);
|
||||
let r = child.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.hint_text("Click to add text")
|
||||
.desired_width(notes_rect.width())
|
||||
.desired_rows(12)
|
||||
.frame(egui::Frame::NONE)
|
||||
.text_color(Color32::from_gray(30))
|
||||
.font(theme::font(14.0)),
|
||||
);
|
||||
if r.lost_focus() && buf != current {
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
let edit_id = egui::Id::new(("notespage-edit", idx));
|
||||
let focused = child.ctx().memory(|m| m.has_focus(edit_id));
|
||||
let notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default();
|
||||
let notes = notes_of(app);
|
||||
let stored: Option<(deckcraft_model::text::TextBody, crate::notes::Draft)> = child.data_mut(|d| d.get_temp(id));
|
||||
let mut draft = stored.filter(|(base, _)| *base == notes).map(|(_, d)| d);
|
||||
// Save an edited draft once the field has lost focus, before anything reloads it.
|
||||
if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) {
|
||||
d.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": d.body, "index": idx}));
|
||||
}
|
||||
child.data_mut(|d| d.insert_temp(id, (current, buf)));
|
||||
let notes = notes_of(app);
|
||||
let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(¬es));
|
||||
let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) };
|
||||
let width = notes_rect.width();
|
||||
let r = crate::notes::editor(&mut child, &mut draft, &look, |e| {
|
||||
e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE)
|
||||
})
|
||||
.response
|
||||
.response;
|
||||
if r.lost_focus() && draft.edited {
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
let base = notes_of(app);
|
||||
child.data_mut(|d| d.insert_temp(id, (base, draft)));
|
||||
let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp)));
|
||||
if nav.0 {
|
||||
let _ = app.run("slide.next", json!({}));
|
||||
|
||||
@@ -28,13 +28,9 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
}
|
||||
// Transient status message.
|
||||
if let Some((msg, at)) = app.status.clone() {
|
||||
if crate::now_ms() - at < 6000.0 {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
} else {
|
||||
app.status = None;
|
||||
}
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let mut right = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
@@ -114,21 +110,30 @@ fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response {
|
||||
resp
|
||||
}
|
||||
|
||||
/// The speaker notes editor under the slide.
|
||||
/// The language of a slide's notes: the first notes run that has one.
|
||||
fn notes_lang(s: &deckcraft_model::Slide) -> Option<String> {
|
||||
s.notes.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone())
|
||||
}
|
||||
|
||||
/// The speaker notes editor under the slide.
|
||||
pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
if st.selection.target != deckcraft_engine::Target::Slides {
|
||||
return;
|
||||
}
|
||||
let idx = st.selection.slide;
|
||||
let uid = st.uid;
|
||||
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
|
||||
let (idx, uid) = (st.selection.slide, st.uid);
|
||||
// Save an edited draft once the field has lost focus or another slide is shown (before
|
||||
// anything reloads it), to the slide it was written on.
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) {
|
||||
app.notes_buf.2.edited = false;
|
||||
if app.notes_buf.0 == uid {
|
||||
let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1}));
|
||||
}
|
||||
}
|
||||
let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default();
|
||||
egui::Panel::bottom("notes")
|
||||
.resizable(true)
|
||||
.default_size(app.ui.notes_height)
|
||||
@@ -139,39 +144,24 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let rect = ui.max_rect();
|
||||
ui.painter()
|
||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||
}
|
||||
// Keep a local draft per slide, following the notes while the field isn't focused.
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) {
|
||||
app.notes_buf = (uid, idx, crate::notes::Draft::new(¬es));
|
||||
}
|
||||
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
let mut draft = app.notes_buf.2.clone();
|
||||
let look = crate::notes::Look { size: 13.0, color: None };
|
||||
// Long notes scroll inside the pane instead of spilling over the slide (#221).
|
||||
let out = egui::ScrollArea::vertical()
|
||||
.max_height(ui.available_height())
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(id)
|
||||
.hint_text("Click to add notes")
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(2)
|
||||
.font(theme::font(13.0))
|
||||
.show(ui)
|
||||
crate::notes::editor(ui, &mut draft, &look, |e| {
|
||||
e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
let r = out.response.clone();
|
||||
let r = out.response.response.clone();
|
||||
let mut buf = draft.text.clone();
|
||||
// Spelling squiggles under misspelt words of the notes.
|
||||
let lang = app.session.active().and_then(|d| d.doc.slides.get(idx)).and_then(|s| notes_lang(s)).unwrap_or_else(|| "en-US".into());
|
||||
let caret = out.cursor_range.map(|c| c.primary.index.0);
|
||||
@@ -243,13 +233,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
if let Some(new) = changed {
|
||||
// A correction is typed into the formatted draft, then saved.
|
||||
buf = new;
|
||||
app.squiggles.notes_menu = None;
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
draft.text = buf.clone();
|
||||
deckcraft_model::edit::replace_text(&mut draft.body, &buf);
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
app.notes_buf.2 = buf.clone();
|
||||
if r.lost_focus() && buf != current {
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
if r.lost_focus() && draft.edited {
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
app.notes_buf.2 = draft;
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
use deckcraft_geom::Rect;
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, Region, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
use egui::{ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Key {
|
||||
@@ -98,6 +98,39 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap `rect` onto the physical pixel grid.
|
||||
///
|
||||
/// The editor draws the slide as a texture of device pixels. A rect that starts or ends
|
||||
/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two
|
||||
/// texels and small text looks soft. On a Retina display that offset is half a point, which
|
||||
/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so
|
||||
/// the same blend is not visible there (#184).
|
||||
pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect {
|
||||
if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() {
|
||||
return rect;
|
||||
}
|
||||
let snap = |v: f32| {
|
||||
let p = v * ppp;
|
||||
if p.is_finite() { p.round() / ppp } else { v }
|
||||
};
|
||||
let min = egui::pos2(snap(rect.min.x), snap(rect.min.y));
|
||||
let step = 1.0 / ppp;
|
||||
let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step));
|
||||
egui::Rect::from_min_max(min, max)
|
||||
}
|
||||
|
||||
/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a
|
||||
/// fraction of a pixel from blurring the glyph; minification still blends when many texels
|
||||
/// share a screen pixel.
|
||||
pub fn editor_texture_options() -> TextureOptions {
|
||||
TextureOptions {
|
||||
magnification: TextureFilter::Nearest,
|
||||
minification: TextureFilter::Linear,
|
||||
wrap_mode: TextureWrapMode::ClampToEdge,
|
||||
mipmap_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
|
||||
let w = img.width as usize;
|
||||
let h = img.height as usize;
|
||||
@@ -175,6 +208,17 @@ pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
|
||||
tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR);
|
||||
}
|
||||
|
||||
/// [`upload`] for editor slide pictures, drawn on the device pixel grid with
|
||||
/// [`editor_texture_options`] (#184).
|
||||
pub fn upload_editor(ctx: &egui::Context, name: impl Into<String>, img: ColorImage) -> TextureHandle {
|
||||
ctx.load_texture(name, shrink_to(img, max_side(ctx)), editor_texture_options())
|
||||
}
|
||||
|
||||
/// [`replace`] for editor slide pictures (#184).
|
||||
pub fn replace_editor(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
|
||||
tex.set(shrink_to(img, max_side(ctx)), editor_texture_options());
|
||||
}
|
||||
|
||||
fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||
}
|
||||
@@ -283,10 +327,10 @@ impl Textures {
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
Some(mut e) => {
|
||||
replace(ctx, &mut e.tex, ci);
|
||||
replace_editor(ctx, &mut e.tex, ci);
|
||||
e.tex
|
||||
}
|
||||
None => upload(ctx, "slide-canvas", ci),
|
||||
None => upload_editor(ctx, "slide-canvas", ci),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
@@ -335,7 +379,7 @@ impl Textures {
|
||||
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));
|
||||
let tex = upload_editor(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 });
|
||||
@@ -350,6 +394,39 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::device_pixel_rect;
|
||||
use egui::{Rect, pos2, vec2};
|
||||
|
||||
fn on_grid(v: f32, ppp: f32) -> bool {
|
||||
let px = v * ppp;
|
||||
(px - px.round()).abs() < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retina_slide_rect_lands_on_device_pixels() {
|
||||
let ppp = 2.0;
|
||||
let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4));
|
||||
let s = device_pixel_rect(r, ppp);
|
||||
assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp));
|
||||
assert!(s.width() > 0.0 && s.height() > 0.0);
|
||||
// One texture pixel per device pixel: the snapped size is an integer pixel count.
|
||||
let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32);
|
||||
assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3);
|
||||
assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3);
|
||||
let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0));
|
||||
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_scale_does_not_move_the_rect() {
|
||||
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
|
||||
assert_eq!(device_pixel_rect(r, 0.0), r);
|
||||
assert_eq!(device_pixel_rect(r, f32::NAN), r);
|
||||
}
|
||||
}
|
||||
|
||||
/// `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) {
|
||||
|
||||
@@ -94,7 +94,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag());
|
||||
let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th));
|
||||
let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)), ppp);
|
||||
rects.push((i, row));
|
||||
let p = ui.painter();
|
||||
let id = doc.slides.get(i).map(|s| s.id);
|
||||
@@ -114,7 +114,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if has_anim {
|
||||
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 = (tw * ppp).round() as u32;
|
||||
let wpx = (img.width() * ppp).round() as u32;
|
||||
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
|
||||
Some(tex) => {
|
||||
p.image(
|
||||
@@ -276,10 +276,11 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 };
|
||||
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
|
||||
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 img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)), ppp);
|
||||
let wpx = ((img.width() * ppp).round() 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),
|
||||
(wpx, (wpx 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(
|
||||
@@ -293,7 +294,7 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false));
|
||||
let tex = crate::textures::upload_editor(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,
|
||||
|
||||
@@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
|
||||
| Method | Params | What it does |
|
||||
|---|---|---|
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) |
|
||||
| `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) |
|
||||
@@ -37,7 +37,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly |
|
||||
| `ui.resize` / `ui.focus` | | Window control |
|
||||
| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) |
|
||||
|
||||
The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude
|
||||
and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell.
|
||||
and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell.
|
||||
@@ -45,7 +45,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests |
|
||||
| TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none |
|
||||
| Animated GIF of the deck | export | n/a | no | | none |
|
||||
| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests |
|
||||
| MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none |
|
||||
| Print | yes | n/a | no | PR #66 (browser) open | none |
|
||||
|
||||
@@ -80,7 +80,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Presentation, slide size, sections (p14), custom shows | yes | yes | |
|
||||
| Slide masters, layouts, placeholders and inheritance | yes | yes | |
|
||||
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
|
||||
| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master |
|
||||
| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master. Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown |
|
||||
| Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
@@ -147,5 +147,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited |
|
||||
| 2026-10-11 | minor | Animated GIF of the deck: export yes (#171) |
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) |
|
||||
+12
-5
@@ -25,6 +25,7 @@
|
||||
> **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-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture options are pane controls, not a new dialog; 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-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)
|
||||
@@ -199,11 +200,13 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Impact:** handouts and speaker notes are printed in many workplaces.
|
||||
- **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.**
|
||||
|
||||
### No video or animated GIF export
|
||||
- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF.
|
||||
### No video export; the animated GIF has no transitions or builds
|
||||
- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes
|
||||
an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF
|
||||
doesn't animate transitions or build animations.
|
||||
- **Impact:** sharing a talk as a video is a common request.
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part,
|
||||
FilmCraft has one to copy).
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for
|
||||
transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy).
|
||||
|
||||
### Embedded MicroType Express font subsets aren't drawn
|
||||
- **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in.
|
||||
@@ -391,7 +394,9 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Action Settings, Print, Insert Object…). 8–14 h.
|
||||
- **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print,
|
||||
Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h.
|
||||
Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in.
|
||||
Format Background picture options (transparency, tile, offset, scale, alignment, mirror, and Apply
|
||||
to All copying a picture) are pane controls on `design.background`, not another dialog. 8–14 h.
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline,
|
||||
colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets
|
||||
and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h.
|
||||
@@ -495,6 +500,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-13 | trivial | Command palette skips commands that need a value, so Export opens the dialog (#79) |
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Format Background picture and solid options are in the existing pane. Dialogs stay 27 |
|
||||
| 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) |
|
||||
| 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words |
|
||||
| 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web |
|
||||
@@ -513,5 +519,6 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 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 |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||
| 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||
@@ -16,8 +16,8 @@ because it mostly follows from hardware use.
|
||||
| Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback |
|
||||
| Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) |
|
||||
| Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same |
|
||||
| High-DPI / Retina | yes | yes | yes | yes | yes |
|
||||
| Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same |
|
||||
| High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes |
|
||||
| Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no |
|
||||
| Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a |
|
||||
| Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no |
|
||||
@@ -80,6 +80,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-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
|
||||
| 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-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) |
|
||||
|
||||
+3
-2
@@ -20,7 +20,7 @@ deck.
|
||||
| `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered |
|
||||
| `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command |
|
||||
| `batch` | Run several commands in order and stop at the first error |
|
||||
| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline |
|
||||
| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline, animated GIF (size, seconds per slide, loop, slides) |
|
||||
| `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) |
|
||||
| `render_slide` | A slide as an image the model can look at |
|
||||
| `add_slide`, `go_to_slide` | Slides by layout |
|
||||
@@ -35,5 +35,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
`shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`,
|
||||
`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any
|
||||
`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}`
|
||||
(GIF). `deckcraft-cli describe COMMAND` prints any
|
||||
command's parameters.
|
||||
@@ -1,6 +1,6 @@
|
||||
# PowerPoint feature checklist
|
||||
|
||||
> **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)
|
||||
> **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; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 82%** over 193 features.
|
||||
@@ -85,7 +85,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P |
|
||||
| Files | Export slides as PNG/JPEG | P0 | D |
|
||||
| Files | Export outline / RTF | P2 | P |
|
||||
| Files | Export video (MP4) / animated GIF | P2 | M |
|
||||
| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P |
|
||||
| Files | Print (slides, handouts, notes, outline) | P1 | M |
|
||||
| Files | Password protection | P2 | M |
|
||||
| Files | Reuse slides from another file | P1 | M |
|
||||
|
||||
@@ -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-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; percentages unchanged; previous: hours to ~95% per audience, ready ~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)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -251,7 +252,9 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
|
||||
latin faces (one scheme on that master, not a library of saved font schemes, and not East
|
||||
Asian or complex-script faces).
|
||||
effect schemes (Design ▸ Effects: DeckCraft, Soft, Crisp, Lift, Glow, Plain),
|
||||
backgrounds (solid, gradient, picture, pattern), slide size presets.
|
||||
backgrounds (solid, gradient, picture, pattern; a solid fill's transparency; picture
|
||||
transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture),
|
||||
slide size presets.
|
||||
- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop,
|
||||
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
|
||||
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
|
||||
@@ -353,6 +356,8 @@ 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-11 | minor | Animated GIF export (#171) moves the checklist's video/GIF row from missing to partial: P2 breadth 36% → 37%; overall breadth (79%), dimension scores and stage unchanged |
|
||||
| 2026-10-14 | minor | Format Background picture and solid options noted under what works today. Percentages, the 226-command count and the d556ff9 dialog snapshot stay as they are |
|
||||
| 2026-10-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% |
|
||||
| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged |
|
||||
| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged |
|
||||
|
||||
+12
-3
@@ -33,6 +33,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-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; dialogs stay 27) · **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-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; 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 (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -98,8 +99,8 @@ 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, 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 Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D |
|
||||
| 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 |
|
||||
| Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters |
|
||||
| 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 |
|
||||
@@ -142,6 +143,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Crop handles, crop to shape, aspect ratio | yes | partial |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
|
||||
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
|
||||
| Drag the frame of a text box being edited to move it | yes | yes |
|
||||
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |
|
||||
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
|
||||
| WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles |
|
||||
@@ -195,8 +198,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93).
|
||||
Colours, spacing, ribbon card and tab geometry follow the observations in
|
||||
`plan/powerpoint/01-observed-ui.md`; the ribbon and menu icons are original (~240, drawn in code)
|
||||
and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0)
|
||||
on the slide as shapes. No pixel comparison against PowerPoint screenshots has been run since
|
||||
2026-10-07. Cross-app icon consistency is open (#73, #56).
|
||||
on the slide as shapes. The editing canvas, thumbnails and layout previews are drawn on the
|
||||
physical pixel grid, so a Retina display does not blur them between pixels (#184). No pixel
|
||||
comparison against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon
|
||||
consistency is open (#73, #56).
|
||||
|
||||
## Estimates
|
||||
|
||||
@@ -246,6 +251,10 @@ 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-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
|
||||
| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize |
|
||||
| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 |
|
||||
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user