diff --git a/README.md b/README.md index a0b03023..073fa482 100644 --- a/README.md +++ b/README.md @@ -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. @@ -155,7 +155,7 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con ## Roadmap -DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core +DeckCraft is in **alpha**. It covers **80% of PowerPoint's features by breadth** (92% of the core ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated). See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next, [docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment, diff --git a/ROADMAP.md b/ROADMAP.md index 6adffddd..db0d810f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,16 @@ > **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) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: web file picker keeps its document and slide, #414; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Font dialog; 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 (progress log: Paragraph dialog; previous: trivial, Font dialog; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 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 (progress log: pen pressure scales ink width; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -45,6 +55,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av | | Value | Kind | |---|---|---| | **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 39%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | +| **Feature breadth** | **80%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | | Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands | | **Ready for real work** | **~55%** | estimated: weighted dimensions below | | Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | @@ -75,7 +86,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | Dimension | Ready | Hours to full | Doc | |---|---:|---|---| -| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) | +| Features (breadth 80% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) | | UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) | | File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) | | Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) | @@ -107,6 +118,8 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) | | Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) | | Slide show and presenter view | 68% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) | +| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) | +| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) | | Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) | | Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) | | Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) | @@ -209,6 +222,19 @@ 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 | +| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged | +| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency | +| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) | +| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step. Breadth stays 79% | +| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | +| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | +| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) | +| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -217,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 | @@ -225,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 | @@ -275,10 +306,22 @@ 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 | +| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) | +| 2026-10-14 | trivial | Progress log: Paragraph dialog | +| 2026-10-14 | trivial | Progress log: Font dialog | +| 2026-10-13 | trivial | Progress log: Grid Options dialog | +| 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: 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 | diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs new file mode 100644 index 00000000..535ce848 --- /dev/null +++ b/apps/deckcraft-cli/src/help.rs @@ -0,0 +1,185 @@ +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] [--automation-read-root DIR] [--automation-write-root DIR]", + 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. + +--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read +(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left +out grants none of its access. They run the in-process server, so they conflict with --connect (a running +app is not confined by them).", + }, + 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 { + 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 = + SUBCOMMANDS.iter().map(|s| format!(" {: ExitCode { let args: Vec = 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 @@ -69,24 +56,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 = 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))), @@ -97,11 +77,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(), } } @@ -109,6 +86,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, @@ -233,7 +226,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")?; @@ -261,7 +254,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}`")), @@ -410,7 +403,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result, 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()) }; @@ -419,6 +412,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})) }; @@ -443,18 +438,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::(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}`")), @@ -479,22 +482,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}{: &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(); @@ -506,7 +563,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()), @@ -543,7 +600,7 @@ fn mcp(args: &[String]) -> Result<(), String> { "--connect" => connect = Some(it.next().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}`")), @@ -580,6 +637,8 @@ so drop --connect (or use --connect without the roots)" #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; fn args(a: &[&str]) -> Vec { @@ -608,6 +667,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 = ["--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)] diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs index e9e1f856..d04e55c7 100644 --- a/apps/deckcraft-cli/tests/cli.rs +++ b/apps/deckcraft-cli/tests/cli.rs @@ -1,118 +1,139 @@ -//! `deckcraft-cli mcp` with the automation root flags, run as the real binary. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use std::process::{Command, Output}; -use std::io::Write; -use std::path::PathBuf; -use std::process::{Command, Output, Stdio}; - -use serde_json::Value; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")) +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() } -fn run(args: &[&str]) -> Output { - bin().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 tmpdir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); +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::>().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(); - dir -} - -/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp `; returns whether it exited -/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. -fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) { - let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); - { - let stdin = child.stdin.as_mut().unwrap(); - writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); - for (i, (name, arguments)) in calls.iter().enumerate() { - let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); - writeln!(stdin, "{call}").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:?}"); } } - let out = child.wait_with_output().unwrap(); - let stdout = String::from_utf8(out.stdout).unwrap(); - let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); - (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) -} - -fn text(reply: &Value) -> &str { - reply["result"]["content"][0]["text"].as_str().unwrap() + 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 mcp_automation_roots_confine_the_server() { - let base = tmpdir("roots"); - let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); - std::fs::create_dir_all(&work).unwrap(); - std::fs::create_dir_all(&elsewhere).unwrap(); - std::fs::write(work.join("in.txt"), "Inside text").unwrap(); - std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap(); - let work_s = work.to_str().unwrap(); - let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string(); - - // Both spellings of the flags; inside works, outside is refused with a readable message. - let read_flag = format!("--automation-read-root={work_s}"); - let calls = [ - ("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})), - ("open_presentation", serde_json::json!({"path": secret})), - ("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})), - ("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})), - ]; - let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); - assert!(success, "{stderr}"); - assert!(stderr.contains("files confined"), "{stderr}"); - assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); - assert_eq!(replies[1]["result"]["isError"], true); - assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); - assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); - assert!(work.join("saved.deckcraft").exists()); - assert_eq!(replies[3]["result"]["isError"], true); - assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3])); - assert!(!elsewhere.join("saved.deckcraft").exists()); - - // A root left out grants none of its access. - let save = [("new_presentation", serde_json::json!({})), calls[2].clone()]; - let (_, _, replies) = mcp_session(&[&read_flag], &save); - assert_eq!(replies[1]["result"]["isError"], true); - assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1])); - - // No flags: nothing is confined. - let (success, _, replies) = mcp_session(&[], &calls[1..2]); - assert!(success); - assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); - let _ = std::fs::remove_dir_all(base); +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 mcp_automation_root_flags_are_checked_up_front() { - let base = tmpdir("roots-args"); - let dir = base.to_str().unwrap(); - let missing = base.join("missing"); - let fails = |args: &[&str], needle: &str| { - let mut full = vec!["mcp"]; - full.extend_from_slice(args); - let o = run(&full); - assert!(!o.status.success(), "{args:?}"); - let err = String::from_utf8_lossy(&o.stderr).into_owned(); - assert!(err.contains(needle), "{args:?}: {err}"); - }; - // The flags confine the in-process server, so they cannot be combined with --connect. - fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); - fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); - fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); - fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); - fails(&["--automation-read-root"], "needs a folder"); - fails(&["--automation-write-root", "--sample"], "needs a folder"); - fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); - fails(&["--automation-write-root="], "needs a non-empty folder"); - // A file is not a folder. - std::fs::write(base.join("f.txt"), "x").unwrap(); - fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); - let _ = std::fs::remove_dir_all(base); +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"); } diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs index 5de25e51..4f162994 100644 --- a/apps/deckcraft-cli/tests/convert.rs +++ b/apps/deckcraft-cli/tests/convert.rs @@ -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"); diff --git a/apps/deckcraft-cli/tests/mcp_roots.rs b/apps/deckcraft-cli/tests/mcp_roots.rs new file mode 100644 index 00000000..e9e1f856 --- /dev/null +++ b/apps/deckcraft-cli/tests/mcp_roots.rs @@ -0,0 +1,118 @@ +//! `deckcraft-cli mcp` with the automation root flags, run as the real binary. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")) +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().unwrap() +} + +fn tmpdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp `; returns whether it exited +/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. +fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) { + let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + for (i, (name, arguments)) in calls.iter().enumerate() { + let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + writeln!(stdin, "{call}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) +} + +fn text(reply: &Value) -> &str { + reply["result"]["content"][0]["text"].as_str().unwrap() +} + +#[test] +fn mcp_automation_roots_confine_the_server() { + let base = tmpdir("roots"); + let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(work.join("in.txt"), "Inside text").unwrap(); + std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap(); + let work_s = work.to_str().unwrap(); + let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string(); + + // Both spellings of the flags; inside works, outside is refused with a readable message. + let read_flag = format!("--automation-read-root={work_s}"); + let calls = [ + ("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})), + ("open_presentation", serde_json::json!({"path": secret})), + ("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})), + ("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})), + ]; + let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); + assert!(success, "{stderr}"); + assert!(stderr.contains("files confined"), "{stderr}"); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert!(work.join("saved.deckcraft").exists()); + assert_eq!(replies[3]["result"]["isError"], true); + assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3])); + assert!(!elsewhere.join("saved.deckcraft").exists()); + + // A root left out grants none of its access. + let save = [("new_presentation", serde_json::json!({})), calls[2].clone()]; + let (_, _, replies) = mcp_session(&[&read_flag], &save); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1])); + + // No flags: nothing is confined. + let (success, _, replies) = mcp_session(&[], &calls[1..2]); + assert!(success); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn mcp_automation_root_flags_are_checked_up_front() { + let base = tmpdir("roots-args"); + let dir = base.to_str().unwrap(); + let missing = base.join("missing"); + let fails = |args: &[&str], needle: &str| { + let mut full = vec!["mcp"]; + full.extend_from_slice(args); + let o = run(&full); + assert!(!o.status.success(), "{args:?}"); + let err = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(err.contains(needle), "{args:?}: {err}"); + }; + // The flags confine the in-process server, so they cannot be combined with --connect. + fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); + fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); + fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); + fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); + fails(&["--automation-read-root"], "needs a folder"); + fails(&["--automation-write-root", "--sample"], "needs a folder"); + fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root="], "needs a non-empty folder"); + // A file is not a folder. + std::fs::write(base.join("f.txt"), "x").unwrap(); + fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 366ea346..6d2b7e94 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -7,7 +7,8 @@ //! Differences from the desktop app: //! - no TCP control channel (browsers can't listen on sockets); //! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive -//! asynchronously through `Services::inbox` (presentations open, media is inserted); +//! asynchronously through `Services::inbox`, applied to the document, slide and operation +//! that started the pick (a dismissed picker or a closed document drops the file); //! - Save and Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - preferences are not persisted. diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f0199a55..dabad8af 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. use deckcraft_engine::Session; -use deckcraft_ui_egui::{Inbox, Services, SlideApp}; +use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp}; use wasm_bindgen::JsCast as _; const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"]; @@ -77,16 +77,23 @@ impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { + // Capture the document and slide now. The bytes arrive later, after the user may + // have switched slides. + let id = self.app.begin_external_file("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes })); + ctx.request_repaint(); + } + Err(e) => { + log::error!("couldn't read dropped file {name}: {e}"); + push_inbox(&inbox, InboxEvent::Cancelled { id }); ctx.request_repaint(); } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } }); } @@ -107,7 +114,7 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { - open_async: Some(Box::new(move |purpose: &str| { + open_async: Some(Box::new(move |purpose: &str, request: u64| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); let dialog = match purpose { @@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { }; wasm_bindgen_futures::spawn_local(async move { let Some(file) = dialog.pick_file().await else { + push_inbox(&inbox, InboxEvent::Cancelled { id: request }); + ctx.request_repaint(); return; }; let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes })); ctx.request_repaint(); }); })), @@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } } +fn push_inbox(inbox: &Inbox, event: InboxEvent) { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event); +} + /// Trigger a browser download of `bytes` named after the last component of `path`. fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); @@ -168,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", diff --git a/apps/deckcraft/src/audio.rs b/apps/deckcraft/src/audio.rs index 53aa48cc..afa74a6a 100644 --- a/apps/deckcraft/src/audio.rs +++ b/apps/deckcraft/src/audio.rs @@ -1,6 +1,7 @@ //! cpal audio output for media playback: the device pulls mixed samples from the //! `deckcraft_media::Player`, which makes it the playback clock video frames follow. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; @@ -29,6 +30,8 @@ fn take_stream_error(slot: &ErrorSlot) -> Option { pub struct CpalOut { stream: Option, error: ErrorSlot, + /// Set by the realtime error callback when the device went away: the stream is dead. + dead: Arc, } impl CpalOut { @@ -56,7 +59,14 @@ impl AudioOut for CpalOut { let rate = cfg.sample_rate().0; let config: cpal::StreamConfig = cfg.clone().into(); let slot = self.error.clone(); - let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e); + let dead = Arc::new(AtomicBool::new(false)); + self.dead = dead.clone(); + let err = move |e: cpal::StreamError| { + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + dead.store(true, Ordering::Relaxed); + } + keep_stream_error(&slot, &e); + }; let stream = match cfg.sample_format() { cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None), cpal::SampleFormat::I16 => { @@ -86,6 +96,13 @@ impl AudioOut for CpalOut { self.stream = None; self.report(); } + + fn failure(&mut self) -> Option { + if !self.dead.swap(false, Ordering::Relaxed) { + return None; + } + Some(take_stream_error(&self.error).unwrap_or_else(|| "the audio device is no longer available".to_string())) + } } #[cfg(test)] diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 5804312b..ef80daaa 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -1,6 +1,7 @@ //! DeckCraft desktop app. //! //! Usage: `deckcraft [--control ] [--sample] [--show] [files…]` +//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits. //! //! `--control ` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. @@ -19,6 +20,8 @@ mod display; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; +#[cfg(not(target_arch = "wasm32"))] +mod pasteboard; use deckcraft_engine::Session; use deckcraft_ui_egui::{Services, SlideApp}; @@ -64,24 +67,16 @@ fn config_dir() -> Option { } } -fn prefs_path() -> Option { - config_dir().map(|b| b.join("ui.json")) -} - fn load_prefs(app: &mut SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() - && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) - { + let Some(dir) = config_dir() else { return }; + let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir); + if let Some(ui) = ui { app.ui = ui; } - if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) - && let Ok(bytes) = std::fs::read(&p) - && let Ok(prefs) = serde_json::from_slice::(&bytes) - { + if let Some(prefs) = prefs { app.session.prefs = prefs; } } @@ -90,14 +85,8 @@ fn save_prefs(app: &SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() { - let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { - let _ = std::fs::write(&p, bytes); - } - if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { - let _ = std::fs::write(p.with_file_name("prefs.json"), bytes); - } + if let Some(dir) = config_dir() { + deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs); } } @@ -124,14 +113,9 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - clipboard_image: Some(Box::new(|| { - let mut cb = arboard::Clipboard::new().ok()?; - let img = cb.get_image().ok()?; - let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?; - let mut out = Vec::new(); - rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) - })), + clipboard_image: Some(Box::new(pasteboard::image_png)), + clipboard_html: Some(Box::new(pasteboard::html)), + clipboard_set_html: Some(Box::new(pasteboard::set_html)), // Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently. audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO") .is_none() @@ -142,6 +126,41 @@ fn services() -> Services { const APP_ID: &str = "ai.storyteller.deckcraft"; +const USAGE: &str = "\ +Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...] + + --control PORT JSON control server on 127.0.0.1:PORT + --sample open the sample presentation + --show start the slide show + --help, -h print this help and exit + --version, -V print the version and exit +"; + +enum Startup { + Help, + Version, + Run { control_port: Option, files: Vec, sample: bool, show: bool }, +} + +/// `--help` and `--version` leave before the window and the log file. +fn startup(mut control_port: Option, args: impl IntoIterator) -> Startup { + let mut files = Vec::new(); + let mut sample = false; + let mut show = false; + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--sample" => sample = true, + "--show" => show = true, + "--version" | "-V" => return Startup::Version, + "--help" | "-h" => return Startup::Help, + _ => files.push(a), + } + } + Startup::Run { control_port, files, sample, show } +} + fn app_icon() -> Option { let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png"); eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() @@ -151,26 +170,21 @@ fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); - let mut control_port: Option = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); - let mut files = Vec::new(); - let mut sample = false; - let mut show = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), - "--sample" => sample = true, - "--show" => show = true, - "--version" => { + let (control_port, files, sample, show) = + match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) { + Startup::Help => { + print!("{USAGE}"); + return Ok(()); + } + Startup::Version => { println!("deckcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - _ => files.push(a), - } - } + Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show), + }; log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); - // The log file lives under the settings directory; opened after the arguments, so `--version` - // leaves no file behind. Records logged until now are written to it first. + // The log file lives under the settings directory; opened after the arguments, so `--help` and + // `--version` leave no file behind. Records logged until now are written to it first. if let Some(logger) = logger { match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { Some(Ok(path)) => log::info!("log file {}", path.display()), @@ -279,4 +293,20 @@ mod tests { assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); } + + #[test] + fn help_exits_instead_of_opening_a_file() { + assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version)); + let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]); + assert!(matches!(launch, Startup::Run { .. })); + if let Startup::Run { control_port, files, sample, show } = launch { + assert_eq!(control_port, Some(4242)); + assert_eq!(files, ["talk.pptx".to_string()]); + assert!(sample); + assert!(!show); + } + } } diff --git a/apps/deckcraft/src/pasteboard.rs b/apps/deckcraft/src/pasteboard.rs new file mode 100644 index 00000000..98626295 --- /dev/null +++ b/apps/deckcraft/src/pasteboard.rs @@ -0,0 +1,77 @@ +//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard). +//! +//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain +//! words. A shape with no text is copied as an image by the UI; this module only reads a picture +//! back (Keynote's TIFF) as PNG bytes. + +/// HTML currently on the pasteboard, when there is some. +pub fn html() -> Option { + let mut cb = arboard::Clipboard::new().ok()?; + let html = cb.get().html().ok()?; + let html = html.trim(); + if html.is_empty() { None } else { Some(html.to_string()) } +} + +/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it. +pub fn set_html(html: &str, text: &str) -> bool { + let Ok(mut cb) = arboard::Clipboard::new() else { return false }; + cb.set_html(html, Some(text)).is_ok() +} + +/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard). +pub fn image_png() -> Option> { + let mut cb = arboard::Clipboard::new().ok()?; + let img = cb.get_image().ok()?; + let width = u32::try_from(img.width).ok()?; + let height = u32::try_from(img.height).ok()?; + let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?; + let mut out = Vec::new(); + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back. + /// Restores whatever plain text was there. Other platforms return immediately. + #[test] + fn html_and_a_picture_round_trip_on_macos() { + if !cfg!(target_os = "macos") { + return; + } + let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()); + let _restore = Restore(previous); + assert!(super::set_html("DeckCraft", "DeckCraft"), "set html"); + let html = super::html().unwrap_or_default(); + assert!(html.contains("") || html.contains(""), "{html}"); + let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default(); + assert_eq!(text, "DeckCraft"); + let mut cb = arboard::Clipboard::new().expect("pasteboard"); + let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) }; + cb.set_image(img).expect("set image"); + drop(cb); + let png = super::image_png().expect("png"); + let decoded = image::load_from_memory(&png).expect("decode").to_rgba8(); + let p = decoded.get_pixel(0, 0); + assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}"); + } + + /// Puts the previous plain text back, including when the test fails halfway. + struct Restore(Option); + + impl Drop for Restore { + fn drop(&mut self) { + let Ok(mut cb) = arboard::Clipboard::new() else { return }; + match self.0.take() { + Some(t) => { + let _ = cb.set_text(t); + } + None => { + let _ = cb.clear(); + } + } + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index cb11317d..893cc065 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -48,3 +48,6 @@ sync_github_name = true [people.Momentum96] sync_github_name = true + +[people.RCShoemaker] +real_name = "Ryan C. Shoemaker" diff --git a/crates/engine/data/thesaurus.txt b/crates/engine/data/thesaurus.txt new file mode 100644 index 00000000..1916eb46 --- /dev/null +++ b/crates/engine/data/thesaurus.txt @@ -0,0 +1,264 @@ +# DeckCraft's built-in thesaurus: headword|synonym, synonym, ... +# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line; +# a headword may appear on several lines (one per sense). +ability|capability, capacity, skill, competence, talent +able|capable, competent, qualified, skilled +accept|agree to, approve, adopt, embrace +accurate|precise, exact, correct, right +achieve|accomplish, attain, reach, realize, deliver +achievement|accomplishment, success, attainment, milestone +add|include, append, attach, insert +advantage|benefit, edge, strength, asset, upside +affect|influence, impact, change, alter +aim|goal, objective, target, purpose, intent +allow|permit, enable, let, authorize +analyze|examine, study, assess, evaluate, review +answer|reply, response, solution +approach|method, strategy, way, technique, plan +area|region, field, domain, zone, sector +arrange|organize, order, sort, plan +ask|request, inquire, query, question +assess|evaluate, gauge, judge, review, appraise +assist|help, aid, support, back +attempt|try, effort, endeavor, bid +bad|poor, weak, inferior, unfavorable, negative +basic|fundamental, essential, core, elementary, simple +begin|start, commence, launch, initiate, open +benefit|advantage, gain, value, payoff, upside +best|finest, top, leading, optimal, premier +big|large, major, substantial, significant, sizable +boost|increase, raise, lift, improve, strengthen +brief|short, concise, succinct, quick +bright|vivid, brilliant, luminous, clever +build|construct, create, develop, assemble, establish +business|company, firm, enterprise, organization, venture +buy|purchase, acquire, obtain +careful|cautious, thorough, meticulous, attentive +cause|reason, source, origin, driver +change|alter, modify, adjust, revise, transform +change|shift, transition, adjustment, revision +cheap|inexpensive, affordable, low-cost, economical +check|verify, confirm, examine, inspect, review +choice|option, alternative, selection, pick +choose|select, pick, opt for, decide on +clear|plain, obvious, evident, transparent, lucid +client|customer, buyer, account, patron +close|near, nearby, adjacent +close|end, finish, conclude, shut +common|frequent, usual, typical, widespread, shared +company|business, firm, organization, enterprise, corporation +complete|finish, conclude, finalize, accomplish +complete|whole, full, entire, total +complex|complicated, intricate, involved, elaborate +concern|worry, issue, problem, risk +consider|weigh, contemplate, evaluate, think about +continue|proceed, carry on, keep going, persist, maintain +cost|price, expense, charge, outlay +create|make, build, produce, design, generate +critical|crucial, vital, essential, key, decisive +current|present, existing, ongoing, latest, up-to-date +customer|client, buyer, consumer, user +data|information, figures, facts, statistics +decide|determine, choose, resolve, settle +decrease|reduce, lower, cut, decline, drop +define|specify, describe, explain, outline +deliver|provide, supply, bring, hand over, ship +demonstrate|show, prove, illustrate, display +describe|explain, outline, portray, depict +design|plan, layout, blueprint, pattern +design|create, plan, devise, draft +develop|build, create, grow, expand, evolve +difference|distinction, contrast, gap, variation +different|distinct, various, diverse, unlike +difficult|hard, challenging, tough, demanding +discuss|talk about, debate, examine, review +easy|simple, straightforward, effortless, painless +effect|result, impact, outcome, consequence +effective|successful, efficient, productive, powerful +effort|work, attempt, endeavor, exertion +end|finish, conclusion, close, completion +enough|sufficient, adequate, ample +ensure|guarantee, make sure, secure, confirm +error|mistake, fault, flaw, bug, slip +essential|vital, crucial, key, necessary, fundamental +establish|set up, found, create, institute +estimate|forecast, projection, approximation, guess +evaluate|assess, appraise, judge, rate, review +event|occasion, function, happening, incident +example|instance, case, illustration, sample +excellent|outstanding, superb, exceptional, first-rate +expand|grow, extend, enlarge, broaden, scale +expect|anticipate, foresee, predict, await +explain|clarify, describe, illustrate, account for +fail|fall short, miss, flop, break down +fast|quick, rapid, swift, speedy +feature|characteristic, attribute, capability, function +final|last, closing, concluding, ultimate +find|discover, locate, identify, uncover +finish|complete, end, conclude, wrap up +focus|concentrate, center, target, emphasize +focus|emphasis, priority, attention, core +future|upcoming, coming, forthcoming, prospective +gain|increase, rise, improvement, profit +get|obtain, acquire, receive, gain +give|provide, offer, supply, grant +goal|aim, objective, target, purpose, ambition +good|fine, great, strong, solid, positive +great|excellent, outstanding, terrific, remarkable +grow|increase, expand, rise, develop, scale +growth|expansion, increase, development, rise +help|assist, aid, support, enable +help|assistance, aid, support, guidance +high|elevated, tall, top, strong +idea|concept, notion, thought, proposal +identify|recognize, pinpoint, detect, determine +impact|effect, influence, consequence, result +important|significant, key, major, crucial, vital +improve|enhance, upgrade, refine, strengthen, boost +include|contain, cover, incorporate, comprise +increase|raise, boost, grow, expand, lift +increase|rise, growth, gain, jump +information|data, details, facts, knowledge +innovative|inventive, creative, original, novel, pioneering +issue|problem, concern, matter, question +job|task, assignment, role, position +keep|retain, maintain, preserve, hold +key|main, central, critical, essential, principal +large|big, sizable, substantial, major, extensive +last|final, latest, previous, closing +launch|start, introduce, roll out, release, unveil +lead|guide, direct, head, steer, run +learn|discover, find out, master, study +less|fewer, smaller, reduced, lower +little|small, minor, slight, modest +look|appear, seem +look|view, glance, examine, inspect +low|minimal, modest, small, reduced +main|primary, chief, principal, central, key +maintain|keep, preserve, sustain, uphold +major|significant, important, key, principal, big +make|create, build, produce, generate +manage|run, handle, oversee, direct, lead +market|industry, sector, segment, marketplace +measure|gauge, assess, quantify, track +meet|satisfy, fulfil, reach, achieve +meeting|session, conference, discussion, review +method|approach, technique, process, procedure, way +model|framework, template, pattern, design +more|additional, extra, further, added +move|shift, transfer, relocate, transition +need|requirement, demand, necessity +need|require, call for, demand, want +new|fresh, recent, novel, modern, latest +next|following, upcoming, subsequent +number|figure, count, total, quantity, amount +objective|goal, aim, target, purpose +offer|provide, propose, extend, present +old|previous, former, legacy, outdated, existing +open|start, launch, begin, unveil +opportunity|opening, chance, prospect, possibility +option|choice, alternative, possibility, route +organize|arrange, structure, coordinate, order +outcome|result, consequence, effect, end result +overview|summary, outline, synopsis, recap +part|portion, piece, segment, section, component +partner|ally, collaborator, associate, affiliate +people|staff, team, individuals, personnel +plan|strategy, scheme, roadmap, proposal, program +plan|organize, arrange, schedule, prepare +point|aspect, detail, issue, argument +popular|well-liked, widespread, in demand, favored +possible|feasible, achievable, viable, potential +present|show, display, introduce, demonstrate +present|current, existing, ongoing +price|cost, rate, charge, fee +priority|focus, concern, precedence, goal +problem|issue, challenge, difficulty, obstacle +process|procedure, method, workflow, system +produce|make, create, generate, manufacture, yield +product|offering, item, solution, good +profit|earnings, return, gain, margin +progress|advance, headway, development, improvement +project|initiative, program, undertaking, venture +propose|suggest, recommend, put forward, offer +provide|give, supply, deliver, offer +purpose|aim, goal, objective, intent, reason +quality|standard, caliber, excellence, grade +question|query, issue, inquiry +quick|fast, rapid, speedy, swift, prompt +rapid|fast, quick, swift, speedy +reach|achieve, attain, hit, arrive at +reason|cause, rationale, motive, basis +recent|latest, new, current, fresh +recommend|suggest, advise, propose, advocate +reduce|cut, lower, decrease, lessen, trim +region|area, territory, zone, market +relevant|pertinent, applicable, related, appropriate +remove|delete, eliminate, take out, drop +replace|substitute, swap, supersede, change +report|account, summary, review, statement +require|need, demand, call for, necessitate +research|study, investigation, analysis, inquiry +resource|asset, supply, means, capacity +result|outcome, effect, consequence, finding +review|assessment, evaluation, examination, analysis +review|examine, assess, evaluate, check, revisit +revenue|income, sales, earnings, turnover +risk|danger, threat, exposure, hazard +role|part, function, position, responsibility +run|operate, manage, conduct, execute +safe|secure, protected, reliable +same|identical, equal, matching, equivalent +save|keep, store, preserve, conserve +see|view, observe, notice, spot +sell|market, offer, trade, retail +service|support, assistance, offering +share|portion, part, stake, percentage +share|distribute, spread, communicate, pass on +show|demonstrate, display, present, reveal, illustrate +significant|important, notable, considerable, major, meaningful +similar|alike, comparable, related, equivalent +simple|easy, straightforward, plain, basic +size|scale, extent, magnitude, dimensions +small|little, minor, compact, modest +solution|answer, fix, remedy, resolution +solve|resolve, fix, settle, work out +specific|particular, precise, exact, distinct +start|begin, launch, commence, kick off, initiate +step|stage, phase, measure, action +strategy|plan, approach, policy, roadmap, tactic +strong|robust, solid, powerful, sturdy +structure|framework, organization, layout, arrangement +success|achievement, accomplishment, win, triumph +successful|effective, thriving, prosperous, winning +suggest|propose, recommend, advise, imply +summary|overview, recap, synopsis, digest +support|help, back, assist, sustain +support|help, assistance, backing, aid +sure|certain, confident, positive, definite +target|goal, objective, aim +task|job, assignment, duty, activity +team|group, crew, staff, squad, unit +test|trial, check, experiment, evaluation +test|try, check, evaluate, verify +think|believe, consider, reckon, suppose +time|period, duration, while, moment +tool|instrument, utility, device, aid +total|sum, whole, aggregate, overall +trend|pattern, tendency, movement, direction +understand|grasp, comprehend, follow, realize +unique|distinctive, singular, one-of-a-kind, unmatched +update|revise, refresh, upgrade, modernize +update|revision, change, upgrade, news +use|employ, apply, utilize, leverage +useful|helpful, practical, handy, valuable +value|worth, benefit, merit, importance +various|diverse, assorted, different, several +view|opinion, perspective, outlook, position +vision|outlook, aspiration, goal, dream +way|method, approach, manner, means +weak|feeble, fragile, poor, limited +well|effectively, successfully, nicely, properly +work|labor, effort, activity, job +work|function, operate, run, succeed +year|annum, twelve months diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 4d969798..c3e27804 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index a7109665..c819ab3a 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -9,6 +9,18 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!( + "guide.add", + "Add Guide", + ["View", "Show", "Guides"], + None, + "{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none", + has_doc, + guide_add + ), + cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move), + cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete), + cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list), cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)), cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), @@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// Guides are kept on the slide; a file can hold at most this many. +const MAX_GUIDES: usize = 200; + +fn guide_limit(s: &Session, vertical: bool) -> Result { + let size = s.doc()?.doc.slide_size; + Ok(if vertical { size.width } else { size.height }) +} + +fn guide_add(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "defaults", false) { + if !s.doc()?.doc.guides.is_empty() { + return Ok(json!({"added": 0})); + } + let size = s.doc()?.doc.slide_size; + return s.edit(|doc, _| { + doc.guides = vec![ + deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 }, + deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 }, + ]; + Ok(json!({"added": 2})) + }); + } + let vertical = bool_or(p, "vertical", true); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit); + if s.doc()?.doc.guides.len() >= MAX_GUIDES { + return Err(bad("guide.add", "too many guides")); + } + s.edit(|doc, _| { + doc.guides.push(deckcraft_model::DrawGuide { vertical, pos }); + Ok(json!({"index": doc.guides.len() - 1})) + }) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?; + if i >= s.doc()?.doc.guides.len() { + return Err(bad(cmd, "no such guide")); + } + Ok(i) +} + +fn guide_move(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.move")?; + let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit); + s.edit(|doc, _| { + if let Some(g) = doc.guides.get_mut(i) { + g.pos = pos; + } + Ok(json!({"index": i, "pos": pos})) + }) +} + +fn guide_delete(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.delete")?; + s.edit(|doc, _| { + if i < doc.guides.len() { + doc.guides.remove(i); + } + ok() + }) +} + +fn guide_list(s: &mut Session, _p: &Value) -> Result { + Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default()) +} + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 599e084c..e2051041 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "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 { "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 { } fn background(s: &mut Session, p: &Value) -> Result { - let bg: Option = 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), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + 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, all: bool, bg: &Option) -> 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 = 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, all: bool, bg: Option) { + 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 = 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 { + 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) { + 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 = 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 { @@ -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)] diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d40ee3a3..687b1639 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::sync::Arc; +use deckcraft_model::text::TextBody; use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId}; use serde_json::{Value, json}; @@ -13,9 +14,17 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo), - cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut), - cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy), - cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste), + cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy), + cmd!( + "edit.paste", + "Paste", + ["Home", "Clipboard"], + Some("Cmd+V"), + "{text?: plain text to paste, html?: rich text from another app, scope?: slides}", + has_doc, + paste + ), cmd!( "edit.pasteSpecial", "Paste Special", @@ -58,7 +67,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool { || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) } -fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { +pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { deckcraft_model::walk(shapes, &mut |s, _| { match &s.kind { deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media), @@ -134,15 +143,41 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { Ok(plain) } +fn copying_caret(s: &Session) -> bool { + s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range())) +} + fn copy(s: &mut Session, p: &Value) -> Result { + // A caret copies nothing; the previous clipboard must not leak into this answer. + let caret = copying_caret(s); let t = copy_inner(s, p)?; - Ok(json!({"text": t})) + let html = if caret { String::new() } else { copied_html(s) }; + Ok(json!({"text": t, "html": html})) +} + +/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes). +fn copied_html(s: &Session) -> String { + if let Some(body) = &s.clipboard.text { + return richclip::body_to_html(body); + } + let mut paragraphs = Vec::new(); + for sh in &s.clipboard.shapes { + if let Some(t) = &sh.text { + paragraphs.extend(t.paragraphs.clone()); + } + } + if paragraphs.is_empty() { + return String::new(); + } + richclip::body_to_html(&TextBody { paragraphs, ..Default::default() }) } fn cut(s: &mut Session, p: &Value) -> Result { + let caret = copying_caret(s); let t = copy_inner(s, p)?; + let html = if caret { String::new() } else { copied_html(s) }; delete(s, p)?; - Ok(json!({"text": t})) + Ok(json!({"text": t, "html": html})) } /// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends @@ -249,9 +284,16 @@ fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model fn paste(s: &mut Session, p: &Value) -> Result { // External plain text (from the system clipboard) wins when given and differs from ours. + // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words. if let Some(t) = str_param(p, "text") && t != s.clipboard.plain { + if let Some(html) = str_param(p, "html") + && let Some(body) = richclip::html_to_body(html) + && richclip::same_words(&body.text(), t) + { + return paste_external_body(s, &body); + } return paste_plain(s, t); } let editing = s.doc()?.selection.text.is_some(); @@ -328,6 +370,33 @@ fn paste(s: &mut Session, p: &Value) -> Result { ok() } +fn paste_external_body(s: &mut Session, body: &TextBody) -> Result { + if s.doc()?.selection.text.is_some() { + return super::text::paste_body(s, body); + } + let plain = body.text(); + let st = s.doc()?; + let size = st.doc.slide_size; + let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0]; + let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?; + let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?; + let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?; + let body = body.clone(); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?; + if let Some(t) = sh.text.as_mut() { + let (wrap, autofit) = (t.body.wrap, t.body.autofit); + *t = body.clone(); + t.body.wrap = wrap; + t.body.autofit = autofit; + } + sel.shapes = vec![ShapeId(id)]; + sel.text = None; + Ok(json!({"id": id})) + }) +} + fn paste_plain(s: &mut Session, text: &str) -> Result { if s.doc()?.selection.text.is_some() { return super::text::insert_text(s, text, None); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4765da60..f96478c1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { 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 { 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> { 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> { } /// 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> { + 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 { @@ -462,6 +470,23 @@ fn export(s: &mut Session, p: &Value) -> Result { 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)?; @@ -587,3 +612,43 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result { #[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); + } +} diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 00000000..4991cd1f --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,240 @@ +//! Home › Editing: Find and Replace across slide text, table cells and speaker notes. + +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.find", + "Find", + ["Home", "Editing"], + None, + "{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)", + has_doc, + find + ), + cmd!( + "text.replace", + "Replace", + ["Home", "Editing"], + None, + "{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}", + has_doc, + replace + ), + ] +} + +/// Where a text body lives on a slide. +#[derive(Clone, Copy, PartialEq, Debug)] +struct Loc { + shape: Option, + cell: Option<(usize, usize)>, +} + +/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes. +fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) { + fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) { + if depth > 64 { + return; + } + for sh in shapes { + let id = Some(u64::from(sh.id.0)); + if let Some(t) = &mut sh.text { + f(Loc { shape: id, cell: None }, t); + } + match &mut sh.kind { + ShapeKind::Table(t) => { + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text); + } + } + } + ShapeKind::Group { children, .. } => rec(children, depth + 1, f), + _ => {} + } + } + } + rec(&mut slide.shapes, 0, f); + f(Loc { shape: None, cell: None }, &mut slide.notes); +} + +struct Opts { + pat: Vec, + case: bool, + whole: bool, +} + +impl Opts { + fn from(p: &Value, cmd: &str) -> Result { + let pat: Vec = str_param(p, "text").unwrap_or("").chars().collect(); + if pat.is_empty() { + return Err(bad(cmd, "missing `text`")); + } + Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) }) + } + fn eq(&self, a: char, b: char) -> bool { + a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase())) + } + /// Non-overlapping matches as char ranges. + fn matches(&self, text: &[char]) -> Vec<(usize, usize)> { + let n = self.pat.len(); + let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_'); + let mut out = vec![]; + let mut i = 0; + while i + n <= text.len() { + let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b)) + && (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n)))); + if hit { + out.push((i, i + n)); + i += n; + } else { + i += 1; + } + } + out + } +} + +fn para_chars(p: &Paragraph) -> Vec { + p.runs.iter().flat_map(|r| r.text.chars()).collect() +} + +/// Matches that lie wholly in plain text runs (never inside a field, equation or break). +fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut off = 0; + for r in &p.runs { + let n = r.text.chars().count(); + spans.push((off, off + n, matches!(r.kind, RunKind::Text))); + off += n; + } + o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect() +} + +/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting. +fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { + let mut off = 0; + let mut placed = false; + for r in &mut p.runs { + let chars: Vec = r.text.chars().collect(); + let (s, e) = (off, off + chars.len()); + off = e; + if e <= a || s >= b { + continue; + } + let keep_head: String = chars[..a.saturating_sub(s)].iter().collect(); + let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect(); + r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") }; + placed = true; + } + if p.runs.len() > 1 { + p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text))); + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.find")?; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + let mut sl = (**sl).clone(); + bodies(&mut sl, &mut |loc, t| { + for (pi, para) in t.paragraphs.iter().enumerate() { + for (a, b) in para_matches(para, &o) { + out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b})); + } + } + }); + } + Ok(Value::Array(out)) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.replace")?; + let with = str_param(p, "with").unwrap_or("").to_string(); + let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> { + let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`"))); + let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))); + Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?)) + }); + let at = at.transpose()?; + let count = s.edit(|doc, _| { + let mut count = 0; + for (i, slp) in doc.slides.iter_mut().enumerate() { + if at.is_some_and(|a| a.0 != i) { + continue; + } + let mut sl = (**slp).clone(); + let mut changed = 0; + bodies(&mut sl, &mut |loc, t| { + if at.is_some_and(|a| a.1 != loc) { + return; + } + for (pi, para) in t.paragraphs.iter_mut().enumerate() { + if at.is_some_and(|a| a.2 != pi) { + continue; + } + for (a, b) in para_matches(para, &o).into_iter().rev() { + if at.is_some_and(|x| x.3 != a) { + continue; + } + splice(para, a, b, &with); + changed += 1; + } + } + }); + if changed > 0 { + *slp = Arc::new(sl); + count += changed; + } + } + if count == 0 { + return Err(bad("text.replace", "no matches")); + } + Ok(count) + })?; + Ok(json!({"replaced": count})) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{Run, RunProps}; + + fn opts(t: &str, case: bool, whole: bool) -> Opts { + Opts { pat: t.chars().collect(), case, whole } + } + + #[test] + fn matching_honours_case_and_whole_words() { + let t: Vec = "Cat cat category".chars().collect(); + assert_eq!(opts("cat", false, false).matches(&t).len(), 3); + assert_eq!(opts("cat", true, false).matches(&t).len(), 2); + assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); + } + + #[test] + fn splice_across_runs_keeps_the_first_runs_format() { + let bold = RunProps { bold: Some(true), ..Default::default() }; + let mut p = Paragraph { + runs: vec![ + Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text }, + Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text }, + ], + ..Default::default() + }; + splice(&mut p, 1, 5, "ola"); + assert_eq!(para_chars(&p).iter().collect::(), "Hola world"); + assert_eq!(p.runs[0].text, "Hola"); + assert_eq!(p.runs[0].props, bold); + } +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 806c54ef..fd7a5232 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -3,7 +3,7 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; -use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TextDir}; +use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape}; use serde_json::{Value, json}; @@ -41,15 +41,7 @@ pub fn specs() -> Vec { 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", @@ -89,6 +81,15 @@ pub fn specs() -> Vec { spacing ), cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear), + cmd!( + "format.fontDialog", + "Font…", + ["Home", "Font"], + Some("Cmd+T"), + "{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog", + has_text_or_shapes, + font_dialog + ), cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow), cmd!( "format.textOutline", @@ -168,6 +169,25 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler), + cmd!( + "format.paragraphDialog", + "Paragraph…", + ["Home", "Paragraph"], + None, + "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog", + has_text_or_shapes, + paragraph_dialog + ), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs; with none of them, opens the Tabs dialog (pos in pt from the text area's left)", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -186,7 +206,7 @@ pub fn specs() -> Vec { "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 ), @@ -391,6 +411,213 @@ fn spacing(s: &mut Session, p: &Value) -> Result { let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); format_run(s, &move |r| r.spacing = Some(v)) } + +/// Character formatting applied together, so the Font dialog is one undo step. +struct FontEdits { + family: Option, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +impl FontEdits { + fn is_empty(&self) -> bool { + self.family.is_none() + && self.size.is_none() + && self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.color.is_none() + && self.strike.is_none() + && self.baseline.is_none() + && self.caps.is_none() + && self.spacing.is_none() + } + + fn write(&self, r: &mut RunProps) { + if let Some(f) = &self.family { + r.font = Some(f.clone()); + } + if let Some(v) = self.size { + r.size = Some(v); + } + if let Some(v) = self.bold { + r.bold = Some(v); + } + if let Some(v) = self.italic { + r.italic = Some(v); + } + if let Some(u) = &self.underline { + r.underline = Some(u.clone()); + } + if let Some(c) = &self.color { + r.fill = Some(Fill::solid(c.clone())); + } + if let Some(v) = self.strike { + r.strike = Some(v); + } + if let Some(v) = self.baseline { + r.baseline = Some(v); + } + if let Some(v) = self.caps { + r.caps = Some(v); + } + if let Some(v) = self.spacing { + r.spacing = Some(v); + } + } +} + +fn font_bool(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), + } +} + +fn font_str<'a>(p: &'a Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), + } +} + +fn font_edits(p: &Value) -> Result { + let family = match font_str(p, "family")? { + None => None, + Some(s) => { + let t = s.trim(); + if t.is_empty() || t.chars().count() > 200 { + return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); + } + Some(t.to_string()) + } + }; + let size = match p.get("size") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; + Some(n.clamp(1.0, 4000.0)) + } + }; + let underline = match font_str(p, "underline")? { + None => None, + Some(s) => { + if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { + return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); + } + Some(s.to_string()) + } + }; + let color = + if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; + let strike = match font_str(p, "strike")? { + None => None, + Some("none") => Some(Strike::None), + Some("sng" | "single") => Some(Strike::Single), + Some("dbl" | "double") => Some(Strike::Double), + Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), + }; + let baseline = match p.get("baseline") { + None => None, + Some(Value::String(s)) => match s.as_str() { + "none" => Some(0.0), + "super" | "superscript" => Some(0.3), + "sub" | "subscript" => Some(-0.25), + _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }, + Some(Value::Number(n)) => { + let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; + Some(f.clamp(-1.0, 1.0)) + } + Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }; + let caps = match font_str(p, "caps")? { + None => None, + Some("none") => Some(Caps::None), + Some("small") => Some(Caps::Small), + Some("all") => Some(Caps::All), + Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), + }; + let spacing = match p.get("spacing") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; + Some(n.clamp(-100.0, 400.0)) + } + }; + Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) +} + +/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. +fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { + use deckcraft_model::ColorBase; + let mut o = serde_json::Map::new(); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().to_string(), json!(v)); + } + } + match &c.base { + ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), + ColorBase::Rgb { rgb } => { + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + Value::Object(o) + } + ColorBase::Scheme { slot } => { + o.insert("scheme".into(), json!(slot.xml_name())); + Value::Object(o) + } + ColorBase::Preset { name } => json!(name), + ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), + } +} + +fn font_dialog_params(s: &Session) -> Value { + let r = current_run(s); + let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); + let baseline = r.baseline.unwrap_or(0.0); + let mut v = json!({ + "family": family, + "size": r.size.unwrap_or(18.0), + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), + "strike": match r.strike { + Some(Strike::Double) => "dbl", + Some(Strike::Single) => "sng", + _ => "none", + }, + "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, + "caps": match r.caps { + Some(Caps::Small) => "small", + Some(Caps::All) => "all", + _ => "none", + }, + "spacing": r.spacing.unwrap_or(0.0), + }); + if let Some(Fill::Solid { color }) = &r.fill { + v["color"] = color_command_value(color); + } + v +} + +fn font_dialog(s: &mut Session, p: &Value) -> Result { + let edits = font_edits(p)?; + if edits.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); + return ok(); + } + format_run(s, &|r| edits.write(r)) +} fn clear(s: &mut Session, _p: &Value) -> Result { format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) } @@ -662,6 +889,179 @@ fn paragraph(s: &mut Session, p: &Value) -> Result { }) } +const PARA_DIALOG_KEYS: &[&str] = + &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; + +fn para_dialog_has_fields(p: &Value) -> bool { + PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) +} + +fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; + Ok(Some(n)) + } + } +} + +fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + Ok(match s { + "left" | "l" => Align::Left, + "center" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + }) +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::JustLow => "justLow", + Align::Distributed => "distributed", + } +} + +fn spacing_parts(s: Option) -> (&'static str, f64) { + match s { + Some(Spacing::Pct(v)) => ("pct", v), + Some(Spacing::Pts(v)) => ("pts", v), + None => ("pts", 0.0), + } +} + +fn paragraph_dialog_params(s: &Session) -> Value { + let pp = current_para(s); + let indent = pp.indent.unwrap_or(0.0); + let (special, by) = if indent > 0.05 { + ("first", indent) + } else if indent < -0.05 { + ("hanging", -indent) + } else { + ("none", 0.0) + }; + let (line_kind, line_value) = match pp.line_spacing { + Some(Spacing::Pts(v)) => ("exactly", v), + Some(Spacing::Pct(v)) => ("multiple", v), + None => ("multiple", 1.0), + }; + let (before_kind, before) = spacing_parts(pp.space_before); + let (after_kind, after) = spacing_parts(pp.space_after); + let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({ + "align": align_name(pp.align.unwrap_or(Align::Left)), + "indentLeft": pp.margin_left.unwrap_or(0.0), + "indentRight": pp.margin_right.unwrap_or(0.0), + "special": special, + "specialBy": by, + "spaceBefore": before, + "spaceBeforeKind": before_kind, + "spaceAfter": after, + "spaceAfterKind": after_kind, + "lineKind": line_kind, + "lineValue": line_value, + "tabs": tabs, + "defaultTab": pp.default_tab.unwrap_or(72.0), + }) +} + +fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { + if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } +} + +fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.paragraphDialog"; + if !para_dialog_has_fields(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); + return ok(); + } + let align = match p.get("align") { + None => None, + Some(Value::String(a)) => Some(parse_align(CMD, a)?), + Some(_) => return Err(bad(CMD, "`align` must be a string")), + }; + let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); + let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); + let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); + let indent = match p.get("special") { + None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), + Some(Value::String(kind)) => { + let by = special_by.unwrap_or(0.0); + Some(match kind.as_str() { + "none" => 0.0, + "first" | "firstLine" => by, + "hanging" | "hang" => -by, + _ => return Err(bad(CMD, "`special` must be none, first or hanging")), + }) + } + Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), + }; + let space_before = finite_key(p, CMD, "spaceBefore")?; + let space_after = finite_key(p, CMD, "spaceAfter")?; + let before_kind = str_param(p, "spaceBeforeKind"); + let after_kind = str_param(p, "spaceAfterKind"); + for kind in [before_kind, after_kind].into_iter().flatten() { + if !matches!(kind, "pts" | "pct") { + return Err(bad(CMD, "spacing kind must be pts or pct")); + } + } + let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); + let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + format_para(s, &move |para| { + if align.is_some() { + para.props.align = align; + } + if indent_left.is_some() { + para.props.margin_left = indent_left; + } + if indent_right.is_some() { + para.props.margin_right = indent_right; + } + if indent.is_some() { + para.props.indent = indent; + } + if let Some(v) = space_before { + para.props.space_before = Some(write_spacing(before_kind, v)); + } + if let Some(v) = space_after { + para.props.space_after = Some(write_spacing(after_kind, v)); + } + if let Some(v) = line_mult { + para.props.line_spacing = Some(Spacing::Pct(v)); + } + if let Some(v) = line_pt { + para.props.line_spacing = Some(Spacing::Pts(v)); + } + }) +} + +fn tab_align(s: &str) -> Option<&'static str> { + Some(match s { + "l" | "left" => "l", + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => return None, + }) +} + +fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) +} + fn anchor(s: &mut Session, p: &Value) -> Result { let a = match str_param(p, "anchor").unwrap_or("top") { "middle" | "center" | "ctr" => Anchor::Middle, @@ -798,6 +1198,7 @@ fn state(s: &mut Session, _p: &Value) -> Result { "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(), })) @@ -825,3 +1226,359 @@ fn font_rescan(_s: &mut Session, _p: &Value) -> Result { let faces = 0usize; Ok(json!({"faces": faces, "families": db.families().len()})) } + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +fn ruler(s: &mut Session, _p: &Value) -> Result { + let props = current_para(s); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; + let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { + (false, Some(sh)) => { + let ctx = super::text::ctx_for(&st.doc, &st.selection); + let x = match &ctx { + Some(c) => resolve::xfrm(c, sh), + None => sh.xfrm.unwrap_or_default(), + }; + let body = match &ctx { + Some(c) => resolve::body(c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); + (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) + } + _ => (0.0, 0.0, 0.0, true), + }; + let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + Ok(json!({ + "x": x, "y": y, "width": w, "rotated": rotated, + "marginLeft": props.margin_left.unwrap_or(0.0), + "indent": props.indent.unwrap_or(0.0), + "tabs": tabs, + "defaultTab": props.default_tab.unwrap_or(72.0), + })) +} + +const MAX_TABS: usize = 32; + +fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); + let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => "l", + }; + let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); + let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); + let remove = fin(p.get("remove").and_then(Value::as_f64)); + // `set` replaces every stop: entries without a usable position are dropped, an unknown + // alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins). + let set = match p.get("set") { + None => None, + Some(Value::Array(items)) => { + if items.len() > 64 { + return Err(bad(CMD, "`set` has more than 64 tab stops")); + } + let mut out: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + return Err(bad(CMD, format!("tab stop {i} must be an object"))); + }; + let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else { + continue; + }; + let align = match obj.get("align").and_then(Value::as_str) { + None => "l", + Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, + }; + if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { + *prev = TabStop { pos, align: align.to_string() }; + } else { + out.push(TabStop { pos, align: align.to_string() }); + } + } + Some(out) + } + Some(_) => return Err(bad(CMD, "`set` must be an array")), + }; + let clear = bool_or(p, "clear", false); + let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); + let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear; + if !edits_stops && default_tab.is_none() { + // No fields: open the Tabs dialog on the caret's paragraph. + s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); + return ok(); + } + // The stops as the caret's paragraph shows them (inherited ones included), so editing a + // placeholder's tab that came from its layout works like editing one set on the paragraph. + let base = current_para(s).tabs.unwrap_or_default(); + let near = |a: f64, b: f64| (a - b).abs() < 0.5; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; + if let Some(at) = remove { + stops.retain(|t| !near(t.pos, at)); + } + if let Some((from, to)) = mv + && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) + { + t.pos = to; + } + if let Some((pos, align)) = add { + stops.retain(|t| !near(t.pos, pos)); + stops.push(TabStop { pos, align }); + } + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops.dedup_by(|a, b| near(a.pos, b.pos)); + stops.truncate(MAX_TABS); + format_para(s, &move |para| { + if edits_stops { + para.props.tabs = Some(stops.clone()); + } + if let Some(d) = default_tab { + para.props.default_tab = Some(d); + } + }) +} + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Align, Caps, ParaProps, Spacing, Strike}; + use deckcraft_model::{ColorBase, Fill, SchemeSlot}; + use serde_json::json; + + use super::*; + use crate::{Session, UiRequest}; + + fn shape() -> Session { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap(); + s + } + + fn props(s: &Session) -> RunProps { + s.doc() + .unwrap() + .selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .unwrap() + .props + .clone() + } + + #[test] + fn font_dialog_applies_character_formatting_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.fontDialog", + &json!({ + "family": "Liberation Serif", + "size": 28, + "bold": true, + "italic": false, + "underline": "wavy", + "color": "#112233", + "strike": "dbl", + "baseline": "super", + "caps": "small", + "spacing": 3 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…"); + let r = props(&s); + assert_eq!(r.font.as_deref(), Some("Liberation Serif")); + assert_eq!(r.size, Some(28.0)); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.italic, Some(false)); + assert_eq!(r.underline.as_deref(), Some("wavy")); + assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. }))); + assert_eq!(r.strike, Some(Strike::Double)); + assert_eq!(r.baseline, Some(0.3)); + assert_eq!(r.caps, Some(Caps::Small)); + assert_eq!(r.spacing, Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none()); + assert_eq!(r.strike, None); + assert_eq!(r.caps, None); + } + + #[test] + fn font_dialog_changes_only_the_fields_it_is_given() { + let mut s = shape(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.size, Some(22.0)); + assert!(r.font.is_none() && r.italic.is_none()); + let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") }; + assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 })); + assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod")); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert!(r.size.is_none() && r.fill.is_none()); + } + + #[test] + fn font_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + s.execute("format.italic", &json!({"on": true})).unwrap(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.fontDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "font"); + assert_eq!(params["italic"], true); + assert_eq!(params["bold"], false); + assert!(params["family"].as_str().is_some_and(|f| !f.is_empty())); + assert!(params["size"].as_f64().is_some_and(|n| n > 0.0)); + } + + #[test] + fn font_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err(); + assert!(err.to_string().contains("underline"), "{err}"); + assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert!(props(&s).underline.is_none()); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); + } + + fn para(s: &Session) -> ParaProps { + s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).and_then(|t| t.paragraphs.first()).unwrap().props.clone() + } + + #[test] + fn paragraph_dialog_applies_alignment_indent_and_spacing_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.paragraphDialog", + &json!({ + "align": "justify", + "indentLeft": 36.0, + "indentRight": 18.0, + "special": "hanging", + "specialBy": 18.0, + "spaceBefore": 6.0, + "spaceAfter": 8.0, + "lineSpacing": 1.5 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…"); + let p = para(&s); + assert_eq!(p.align, Some(Align::Justify)); + assert_eq!(p.margin_left, Some(36.0)); + assert_eq!(p.margin_right, Some(18.0)); + assert_eq!(p.indent, Some(-18.0)); + assert_eq!(p.space_before, Some(Spacing::Pts(6.0))); + assert_eq!(p.space_after, Some(Spacing::Pts(8.0))); + assert_eq!(p.line_spacing, Some(Spacing::Pct(1.5))); + s.execute("edit.undo", &json!({})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote"); + assert!(p.margin_left.is_none() && p.indent.is_none() && p.space_before.is_none() && p.line_spacing.is_none()); + } + + #[test] + fn paragraph_dialog_first_line_and_exact_spacing_replace_only_those_fields() { + let mut s = shape(); + s.execute("format.paragraphDialog", &json!({"align": "right"})).unwrap(); + s.execute("format.paragraphDialog", &json!({"special": "first", "specialBy": 27.0, "lineSpacingPt": 20.0})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Right)); + assert_eq!(p.indent, Some(27.0)); + assert_eq!(p.line_spacing, Some(Spacing::Pts(20.0))); + assert!(p.margin_left.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).align, Some(Align::Right)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn paragraph_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.paragraphDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "paragraph"); + assert_eq!(params["align"], "center", "a new shape's text is centred"); + assert_eq!(params["special"], "none"); + assert_eq!(params["lineKind"], "multiple"); + assert!(params["lineValue"].as_f64().is_some_and(|v| v > 0.0)); + } + + #[test] + fn paragraph_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + assert!(s.execute("format.paragraphDialog", &json!({"align": "sparkle"})).unwrap_err().to_string().contains("align")); + assert!(s.execute("format.paragraphDialog", &json!({"special": "middle"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"indentLeft": "wide"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"lineSpacing": "double"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"spaceBeforeKind": "lines", "spaceBefore": 4.0})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(para(&s).align, Some(Align::Center)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.tabs", + &json!({"set": [{"pos": 144.0, "align": "center"}, {"pos": 72.0, "align": "dec"}, {"pos": 72.02, "align": "right"}], "defaultTab": 36.0}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Tabs…"); + let p = para(&s); + let tabs = p.tabs.unwrap(); + assert_eq!(tabs.len(), 2, "stops within 0.05 pt collapse"); + assert_eq!(tabs[0].pos, 72.02); + assert_eq!(tabs[0].align, "r"); + assert_eq!(tabs[1].align, "ctr"); + assert_eq!(p.default_tab, Some(36.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(para(&s).tabs.is_none() && para(&s).default_tab.is_none()); + + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "tabs"); + assert!(params["tabs"].as_array().is_some_and(|a| a.is_empty())); + assert!(s.execute("format.tabs", &json!({"set": [{"pos": 10.0, "align": "sparkle"}]})).is_err()); + assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + } +} diff --git a/crates/engine/src/cmd/guides_tests.rs b/crates/engine/src/cmd/guides_tests.rs new file mode 100644 index 00000000..80bf3e68 --- /dev/null +++ b/crates/engine/src/cmd/guides_tests.rs @@ -0,0 +1,76 @@ +//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and +//! the PPTX round trip. + +use serde_json::json; + +use crate::Session; +use crate::tools::{Mods, PointerEvent, PointerKind}; + +fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); +} + +#[test] +fn add_move_delete_and_undo() { + let mut s = Session::with_new(); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none"); + let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap(); + assert_eq!(i, 2); + s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide"); + assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err()); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err()); + s.execute("guide.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 3); +} + +#[test] +fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + // Hidden guides can't be grabbed: the press starts a marquee instead. + ev(&mut s, PointerKind::Down, 300.0, 50.0); + ev(&mut s, PointerKind::Up, 300.0, 50.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0); + s.tool.guides_visible = true; + ev(&mut s, PointerKind::Down, 301.0, 50.0); + ev(&mut s, PointerKind::Drag, 350.4, 60.0); + assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging"); + ev(&mut s, PointerKind::Up, 350.4, 60.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0); + assert_eq!(s.tool.guide_drag, None); + + // A shape dragged near the guide snaps its left edge onto it. + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap(); + s.prefs.snap_to_grid = false; + ev(&mut s, PointerKind::Down, 140.0, 230.0); + ev(&mut s, PointerKind::Drag, 200.0, 230.0); + ev(&mut s, PointerKind::Drag, 388.0, 231.0); + ev(&mut s, PointerKind::Up, 388.0, 231.0); + let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap(); + assert!((x.x - 350.0).abs() < 0.01, "{x:?}"); + + // Dropped off the slide: gone, and undo brings it back. + ev(&mut s, PointerKind::Down, 350.0, 10.0); + ev(&mut s, PointerKind::Drag, -40.0, 10.0); + ev(&mut s, PointerKind::Up, -40.0, 10.0); + assert!(s.doc().unwrap().doc.guides.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 1); +} + +#[test] +fn guides_round_trip_through_pptx() { + let mut s = Session::with_new(); + s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap(); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert_eq!(back.guides, s.doc().unwrap().doc.guides); + let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap(); + assert!(none.guides.is_empty()); +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index fb666abc..9da535e0 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -10,7 +10,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { 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 { "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, })) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b4770c19..cb0b41ef 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub mod background; pub mod design; pub mod edit; pub mod file; +pub mod find; pub mod format; pub mod ideas; pub mod insert; @@ -17,6 +18,7 @@ pub mod picture_style; pub mod points; pub mod recolor; pub mod review; +mod richclip; pub mod selection; pub mod shape; pub mod slide; @@ -41,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, @@ -61,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 { + 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 { @@ -87,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()) } @@ -97,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()) } } @@ -152,6 +178,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); + v.extend(find::specs()); v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); @@ -164,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, 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>, String> { + let doc = doc.split('→').next().unwrap_or_default(); + let mut params: Vec = vec![]; + let mut open: Vec = vec![]; + let mut expect_key = false; + let mut or = false; + let mut objects: usize = 0; + let mut about_from: Option = 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, 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::())), + }; + 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) -> EngineError { diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index a19a80a6..50eb4c5c 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -46,6 +46,20 @@ pub fn specs() -> Vec { ), 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!( + "review.replaceWord", + "Insert Synonym", + [], + None, + "{with}: replace the selected word (or the word at the caret) while editing text", + has_text, + insert_synonym + ), + cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare), + cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions), + cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept), + cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { let i = s.doc()?.selection.slide; @@ -581,6 +595,116 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { ok() } +/// The selected text when it lies in one paragraph, else the word at the caret. +fn word_under_caret(s: &Session) -> Option { + let st = s.doc().ok()?; + let t = st.selection.text.clone()?; + let body = super::text::body_of(st, &t)?; + let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) }; + if a.0 != b.0 { + return None; + } + let text = body.paragraphs.get(a.0)?.text(); + let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect(); + let w = w.trim().to_string(); + (!w.is_empty()).then_some(w) +} + +fn thesaurus(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default(); + let senses = crate::thesaurus::lookup(&word); + Ok(json!({"word": word, "senses": senses})) +} + +fn insert_synonym(s: &mut Session, p: &Value) -> Result { + let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string(); + if with.chars().count() > 200 { + return Err(bad("review.replaceWord", "too long")); + } + let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret); + if collapsed { + s.execute("text.selectWord", &json!({}))?; + } + s.execute("text.insert", &json!({"text": with})) +} + +fn comparing(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } +} + +fn revised_bytes(p: &Value) -> Result<(String, Vec)> { + if let Some(data) = str_param(p, "data") { + let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = str_param(p, "path") { + let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?; + return Ok((path.to_string(), bytes)); + } + Err(bad("review.compare", "give `path` or `data`")) +} + +fn revisions_json(st: &crate::DocState) -> Result { + let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?; + let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone()); + Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position})) +} + +fn compare(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = revised_bytes(p)?; + let revised = super::file::open_presentation(&name, &bytes)?; + let st = s.doc_mut()?; + let by_position = crate::compare::unrelated(&st.doc, &revised); + st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position }); + s.ui_requests.push(UiRequest::Pane { id: "revisions".into() }); + revisions_json(s.doc()?) +} + +fn revisions(s: &mut Session, _: &Value) -> Result { + revisions_json(s.doc()?) +} + +fn accept(s: &mut Session, p: &Value) -> Result { + let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?; + let all = bool_param(p, "all").unwrap_or(false); + let index = usize_param(p, "index"); + if !all && index.is_none() { + return Err(bad("review.accept", "give `index` or `all`")); + } + s.edit(|doc, sel| { + let mut accepted = 0usize; + // Each accept shifts indices, so the list is recomputed; bounded by the slide counts. + let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1); + for _ in 0..limit { + let changes = crate::compare::diff(doc, &rev, by_pos); + let c = if all { + changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1)) + } else { + changes.into_iter().nth(index.unwrap_or(0)) + }; + let Some(c) = c else { break }; + let Some(at) = crate::compare::accept(doc, &rev, &c) else { break }; + accepted += 1; + sel.slide = at; + sel.shapes.clear(); + sel.text = None; + if !all { + break; + } + } + if accepted == 0 { + return Err(bad("review.accept", "no such revision")); + } + Ok(json!({"accepted": accepted})) + }) +} + +fn end_compare(s: &mut Session, _: &Value) -> Result { + s.doc_mut()?.compare = None; + ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs new file mode 100644 index 00000000..69f70686 --- /dev/null +++ b/crates/engine/src/cmd/richclip.rs @@ -0,0 +1,346 @@ +//! Rich text on the system clipboard, as HTML. +//! +//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes +//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive. +//! Anything else in the markup is skipped. A hostile fragment is capped, never executed. + +use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody}; + +const MAX_HTML: usize = 1 << 20; +const MAX_PARAS: usize = 2_000; +const MAX_RUNS: usize = 10_000; + +/// HTML for `body`: one `

` per paragraph, ``, `` and `` for the formatting. +pub fn body_to_html(body: &TextBody) -> String { + let mut out = String::new(); + for (i, p) in body.paragraphs.iter().enumerate() { + if i > 0 { + out.push_str("
"); + } + out.push_str("

"); + for r in &p.runs { + let (open, close) = tags(&r.props); + out.push_str(&open); + match &r.kind { + deckcraft_model::text::RunKind::Break => out.push_str("
"), + _ => push_escaped(&mut out, &r.text), + } + out.push_str(&close); + if out.len() > MAX_HTML { + break; + } + } + out.push_str("

"); + } + out +} + +fn tags(props: &RunProps) -> (String, String) { + let mut open = String::new(); + let mut close = String::new(); + if props.bold == Some(true) { + open.push_str(""); + close.insert_str(0, ""); + } + if props.italic == Some(true) { + open.push_str(""); + close.insert_str(0, ""); + } + if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) { + open.push_str(""); + close.insert_str(0, ""); + } + (open, close) +} + +fn push_escaped(out: &mut String, text: &str) { + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } +} + +/// The formatted text of an HTML fragment, or `None` when it has no text. +/// +/// Understands ``, ``, ``, ``, ``, `
`, paragraphs, and a `style` that +/// says bold, italic or underline. Other tags are ignored. The result is capped. +pub fn html_to_body(html: &str) -> Option { + if html.len() > MAX_HTML { + return None; + } + let bytes = html.as_bytes(); + let mut i = 0; + let mut bold = 0i32; + let mut italic = 0i32; + let mut underline = 0i32; + let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new(); + let mut paragraphs: Vec = vec![Paragraph::default()]; + let mut runs = 0usize; + let mut skip = 0i32; + while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS { + if bytes[i] == b'<' { + let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len()); + let raw = &html[i + 1..end]; + i = (end + 1).min(bytes.len()); + let token = raw.trim(); + if token.starts_with('!') || token.starts_with('?') { + continue; + } + let closing = token.starts_with('/'); + let name_src = token.trim_start_matches('/').trim(); + let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len()); + let name = name_src[..name_end].to_ascii_lowercase(); + if name == "script" || name == "style" || name == "head" { + if closing { + skip = (skip - 1).max(0); + } else if !token.ends_with('/') { + skip += 1; + } + continue; + } + if skip > 0 { + continue; + } + if closing { + let kind = tag_kind(&name); + if !kind.is_empty() + && let Some(pos) = stack.iter().rposition(|e| e.0 == kind) + { + let (_, db, di, du) = stack[pos]; + bold -= db; + italic -= di; + underline -= du; + stack.truncate(pos); + } + if is_block(&name) { + new_para(&mut paragraphs); + } + continue; + } + if is_break(&name) { + new_para(&mut paragraphs); + continue; + } + let (db, di, du) = tag_style(&name, name_src); + if db + di + du > 0 { + bold += db; + italic += di; + underline += du; + if !token.ends_with('/') { + stack.push((tag_kind(&name), db, di, du)); + } else { + bold -= db; + italic -= di; + underline -= du; + } + } + continue; + } + if skip > 0 { + i += 1; + continue; + } + let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len()); + let text = decode_text(&html[i..end]); + i = end; + if text.is_empty() { + continue; + } + push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0); + } + while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 { + paragraphs.pop(); + } + if paragraphs.iter().all(Paragraph::is_empty) { + return None; + } + for p in &mut paragraphs { + p.normalize(); + } + Some(TextBody { paragraphs, ..Default::default() }) +} + +fn tag_kind(name: &str) -> &'static str { + match name { + "b" | "strong" => "b", + "i" | "em" => "i", + "u" => "u", + "span" | "font" => "span", + "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block", + _ => "", + } +} + +fn is_break(name: &str) -> bool { + name == "br" || name == "hr" +} + +fn is_block(name: &str) -> bool { + matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote") +} + +fn tag_style(name: &str, src: &str) -> (i32, i32, i32) { + let mut bold = i32::from(name == "b" || name == "strong"); + let mut italic = i32::from(name == "i" || name == "em"); + let mut underline = i32::from(name == "u"); + if let Some(style) = attr(src, "style") { + let s = style.to_ascii_lowercase(); + if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) { + bold = 1; + } + if s.contains("font-style:italic") || s.contains("font-style: italic") { + italic = 1; + } + if s.contains("text-decoration:underline") + || s.contains("text-decoration: underline") + || s.contains("text-decoration-line:underline") + || s.contains("text-decoration-line: underline") + { + underline = 1; + } + } + (bold, italic, underline) +} + +fn weight_bold(style: &str) -> bool { + let Some(at) = style.find("font-weight") else { return false }; + let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace()); + rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9') +} + +fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> { + let lower = tag.to_ascii_lowercase(); + let at = lower.find(key)?; + let after = tag.get(at + key.len()..)?.trim_start(); + let after = after.strip_prefix('=')?.trim_start(); + let quote = after.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let rest = after.get(quote.len_utf8()..)?; + let end = rest.find(quote)?; + rest.get(..end) +} + +fn new_para(paragraphs: &mut Vec) { + if paragraphs.len() >= MAX_PARAS { + return; + } + if paragraphs.last().is_some_and(|p| !p.is_empty()) { + paragraphs.push(Paragraph::default()); + } +} + +fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) { + if *runs >= MAX_RUNS { + return; + } + let Some(p) = paragraphs.last_mut() else { return }; + let mut props = RunProps::default(); + if bold { + props.bold = Some(true); + } + if italic { + props.italic = Some(true); + } + if underline { + props.underline = Some("sng".into()); + } + p.runs.push(Run::with(text, props)); + *runs += 1; +} + +fn decode_text(raw: &str) -> String { + let mut out = String::new(); + let mut rest = raw; + while let Some(at) = rest.find('&') { + out.push_str(&rest[..at].replace(['\n', '\r'], "")); + rest = &rest[at..]; + let Some(end) = rest.find(';') else { + out.push_str(&rest.replace(['\n', '\r'], "")); + break; + }; + let entity = &rest[1..end]; + rest = &rest[end + 1..]; + match entity { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + "nbsp" => out.push(' '), + _ => { + let ch = entity.strip_prefix('#').and_then(numeric_char); + if let Some(c) = ch { + out.push(c); + } + } + } + } + if !rest.is_empty() && !rest.starts_with('&') { + out.push_str(&rest.replace(['\n', '\r'], "")); + } + out +} + +fn numeric_char(digits: &str) -> Option { + let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X')); + let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? }; + char::from_u32(n).filter(|c| !c.is_control() || *c == '\n') +} + +/// Do the HTML's words and the paste's plain text say the same thing? +pub fn same_words(html_plain: &str, pasted: &str) -> bool { + let a = fold(html_plain); + let b = fold(pasted); + a == b +} + +fn fold(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bold_italic_and_underline_round_trip() { + let mut body = TextBody::from_text("Hi there"); + body.paragraphs[0].runs = vec![ + Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }), + Run::new(" "), + Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }), + ]; + let html = body_to_html(&body); + assert!(html.contains("Hi"), "{html}"); + assert!(html.contains("there") || html.contains("there"), "{html}"); + let back = html_to_body(&html).unwrap(); + assert_eq!(back.text(), "Hi there"); + assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true)); + let last = back.paragraphs[0].runs.last().unwrap(); + assert_eq!(last.props.italic, Some(true)); + assert!(last.props.underline.is_some()); + } + + #[test] + fn pages_style_and_escapes() { + let html = "

A & B

C

"; + let body = html_to_body(html).unwrap(); + assert_eq!(body.text(), "A & B\nC"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true)); + assert!(same_words(&body.text(), "A & B\nC")); + assert!(!same_words(&body.text(), "other")); + } + + #[test] + fn a_huge_fragment_is_refused() { + let html = "x".repeat(MAX_HTML + 1); + assert!(html_to_body(&html).is_none()); + } +} diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs new file mode 100644 index 00000000..e920ce93 --- /dev/null +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -0,0 +1,64 @@ +//! The ruler's data and tab-stop editing. + +use serde_json::json; + +use crate::Session; + +fn editing() -> (Session, u64) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "a\tb"})).unwrap(); + (s, id) +} + +#[test] +fn ruler_reports_the_text_area_and_indents() { + let (mut s, _) = editing(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}"); + assert_eq!(r["rotated"], false); + assert_eq!(r["tabs"].as_array().unwrap().len(), 0); + s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0))); + s.execute("text.exit", &json!({})).unwrap(); + assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text"); +} + +#[test] +fn tabs_add_move_remove_clear_and_undo() { + let (mut s, _) = editing(); + let tabs = |s: &mut Session| -> Vec<(f64, String)> { + s.execute("format.ruler", &json!({})).unwrap()["tabs"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string())) + .collect() + }; + s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap(); + s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted"); + s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced"); + s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap(); + s.execute("format.tabs", &json!({"remove": 72.2})).unwrap(); + assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tabs(&mut s).len(), 2); + s.execute("format.tabs", &json!({"clear": true})).unwrap(); + assert!(tabs(&mut s).is_empty()); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog"); + assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); + for i in 0..100 { + s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); + } + assert!(tabs(&mut s).len() <= 32); + s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap(); + assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped"); + assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0)); +} diff --git a/crates/engine/src/cmd/selection_tests.rs b/crates/engine/src/cmd/selection_tests.rs index 11d99b7c..69bcc3c8 100644 --- a/crates/engine/src/cmd/selection_tests.rs +++ b/crates/engine/src/cmd/selection_tests.rs @@ -201,7 +201,7 @@ fn locked_objects_are_picked_on_the_canvas_but_not_dragged() { s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); s.execute("edit.select", &json!({"ids": []})).unwrap(); let n = s.doc().unwrap().history.undo.len(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // A: x 20..120, y 50..130. s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap(); assert_eq!(selected(&s), vec![a], "a click selects a locked object"); diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 2790f488..7faffb34 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -2,7 +2,7 @@ //! names and alt text. use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; -use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow}; +use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow}; use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; use serde_json::{Value, json}; @@ -29,11 +29,36 @@ pub fn specs() -> Vec { has_slide, freeform ), + cmd!( + "draw.inkToShape", + "Ink to Shape", + ["Draw", "Convert"], + None, + "{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles", + has_slide, + ink_to_shape + ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), - cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), - cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), + cmd!( + "shape.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", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate), + cmd!( + "shape.rotate", + "Rotation", + ["Shape Format", "Arrange", "Rotate"], + None, + "{deg?: absolute, by?: relative, ids?, gesture?: move|end}", + has_selection, + rotate + ), cmd!( "shape.sizePosition", "Size and Position…", @@ -46,12 +71,21 @@ pub fn specs() -> Vec { cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip), cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust), cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change), + cmd!( + "shape.smoothCorners", + "Smooth Corners", + ["Shape Format", "Insert Shapes", "Edit Shape"], + None, + "{smoothing: 0..100 (%), radius?: pt (default: the rounded rectangle's own), ids?} → rectangle outline with smoothed (squircle) corners", + has_selection, + smooth_corners + ), cmd!( "shape.fill", "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}", + "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill", has_selection, fill ), @@ -95,7 +129,15 @@ pub fn specs() -> Vec { 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", @@ -336,6 +378,15 @@ fn size_position(s: &mut Session, p: &Value) -> Result { } fn rotate(s: &mut Session, p: &Value) -> Result { + // A trackpad twist sends many small steps. `gesture: move` keeps them as one undo; + // `gesture: end` commits that step and does not rotate again. + if p.get("gesture").and_then(Value::as_str) == Some("end") { + s.commit_named("Rotation"); + return Ok(json!({})); + } + if p.get("gesture").and_then(Value::as_str) == Some("move") { + s.begin("Rotation"); + } let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); edit_geometry(s, p, "shape.rotate", |sh| { if let Some(x) = sh.xfrm.as_mut() { @@ -378,6 +429,47 @@ fn adjust(s: &mut Session, p: &Value) -> Result { }) } +/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed +/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says. +fn smooth_corners(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "shape.smoothCorners"; + let smoothing = + f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0; + let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0)); + edit_geometry(s, p, CMD, |sh| { + let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) }; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0), + Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + // A custom outline qualifies only when it is a squircle this command drew with `radius` + // (re-smoothing as the slider moves), never an arbitrary freeform. + let custom = match (&sh.geom, radius) { + (Geom::Custom { paths }, Some(r)) => match paths.as_slice() { + [only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d), + _ => false, + }, + _ => false, + }; + let r = match (radius, own) { + (Some(r), _) if own.is_some() || custom => r, + (None, Some(r)) => r, + _ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")), + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: x.w, + h: x.h, + d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing), + ..Default::default() + }], + }; + Ok(()) + }) +} + fn change(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; @@ -452,8 +544,9 @@ fn fill(s: &mut Session, p: &Value) -> Result { None }; let transparency = f64_param(p, "transparency"); - if new_fill.is_none() && transparency.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`")); + let pic_props = p.get("pictureProps").filter(|v| v.is_object()); + if new_fill.is_none() && transparency.is_none() && pic_props.is_none() { + return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`")); } // Transparency on an inherited (style) fill needs the effective colour. let st = s.doc()?; @@ -481,6 +574,14 @@ fn fill(s: &mut Session, p: &Value) -> Result { if let Some(f) = &new_fill { sh.fill.clone_from(f); } + if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) { + picture_props(pf, pp); + } + if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); + return Ok(()); + } if let Some(t) = transparency { let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone())); if let Some(Fill::Solid { mut color }) = base { @@ -495,6 +596,54 @@ fn fill(s: &mut Session, p: &Value) -> Result { }) } +/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. +fn picture_props(pf: &mut PictureFill, pp: &Value) { + 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); + } + } + } + } + } +} + fn line_end(v: &str) -> Option { if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } } @@ -1246,3 +1395,127 @@ mod size_position_tests { assert!(err.to_string().contains("nothing selected"), "{err}"); } } + +/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape +/// outlined in the stroke's colour and width; the rest stays ink. One undo step. +fn ink_to_shape(s: &mut Session, _p: &Value) -> Result { + use crate::inkshape::{Recognized, recognize}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let shapes = s.doc()?.shapes(); + let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else { + return Ok(json!({"shapes": [], "kept": 0})); + }; + let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0))); + let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) }; + let found: Vec> = strokes + .iter() + .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::>()) }) + .collect(); + if found.iter().all(Option::is_none) { + return Ok(json!({"shapes": [], "kept": strokes.len()})); + } + s.edit(|doc, sel| { + let mut new = vec![]; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) }; + let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) }; + let all = std::mem::take(strokes); + let mut kept = vec![]; + let mut made = vec![]; + for (st, r) in all.into_iter().zip(found.iter()) { + let Some(r) = r else { + kept.push(st); + continue; + }; + let (preset, xfrm) = match *r { + Recognized::Line { x0, y0, x1, y1 } => { + ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0)) + } + Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }), + }; + made.push((preset, xfrm, st.color, st.width)); + } + let remaining = kept.len(); + *strokes = kept; + if remaining == 0 { + list.remove(pos); + } + for (preset, xfrm, color, width) in made { + let id = ShapeId(doc.alloc_id()); + let name = format!( + "{} {}", + match preset { + "line" => "Straight Connector", + "rect" => "Rectangle", + "ellipse" => "Oval", + _ => "Isosceles Triangle", + }, + id.0 + ); + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + list.push(Shape { + id, + name, + xfrm: Some(xfrm), + geom: Geom::preset(preset), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))), + kind: ShapeKind::Shape, + ..Default::default() + }); + new.push(id); + } + sel.shapes = new.clone(); + Ok(json!({"shapes": new.iter().map(|i| i.0).collect::>(), "kept": remaining})) + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use serde_json::json; + + fn rotation(s: &Session) -> f64 { + let st = s.active().unwrap(); + let id = st.selection.shapes[0]; + st.shape(id).unwrap().xfrm.unwrap().rot + } + + #[test] + fn a_twist_is_one_undo_and_wraps_past_a_full_turn() { + let mut s = Session::with_new(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap(); + let id = id.get("id").and_then(Value::as_u64).unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01); + + s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap(); + s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s)); + s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap(); + s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); + assert!((rotation(&s) - 20.0).abs() < 0.01); + assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation"); + assert!(s.active().unwrap().interaction.is_none()); + + s.execute("edit.undo", &json!({})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!((rotation(&s) - 20.0).abs() < 0.01); + } + + #[test] + fn ending_a_twist_does_not_commit_a_drag() { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap(); + let st = s.active_mut().unwrap(); + st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() }); + s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); + assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move"); + } +} diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index b90c0abb..deb702c3 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -32,7 +32,15 @@ pub fn specs() -> Vec { 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 { fn notes(s: &mut Session, p: &Value) -> Result { 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::(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(()) })?; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 373c94c1..1c5b080c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -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 { vec![ @@ -27,7 +28,15 @@ pub fn specs() -> Vec { 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 { "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 { 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 { ] } +/// 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, + /// Row heights as drawn: a stored height is a minimum, rows grow to fit their text. + pub heights: Vec, + /// 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 { + 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 { + 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 { + 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 { fn column_width(s: &mut Session, p: &Value) -> Result { 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(()) }) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 23b220c7..8cb05ca3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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::{Align, AutoFit, Hyperlink, Paragraph, 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(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { - 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 Option { - 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 @@ -1170,7 +1195,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 { @@ -1260,7 +1285,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 { diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 4c798125..4047faf2 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs new file mode 100644 index 00000000..5d65c0cc --- /dev/null +++ b/crates/engine/src/compare.rs @@ -0,0 +1,449 @@ +//! Review › Compare: the differences between the open presentation and a revised copy of it, slide +//! by slide, and accepting a revised slide into the open one. +//! +//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no +//! slide id at all, by position. Shapes match by id, then by name. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId}; +use serde::Serialize; + +/// The revised presentation a document is being compared with. +#[derive(Clone, Debug)] +pub struct Compare { + pub name: String, + pub revised: Arc, + /// Slides match by position (the decks shared no slide id when the compare started); fixed + /// for the session so accepting slides doesn't switch the matching half way. + pub by_position: bool, +} + +/// Whether `cur` and `rev` share no slide id (then slides match by position). +pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool { + let ids: HashSet = rev.slides.iter().map(|s| s.id).collect(); + !cur.slides.iter().any(|s| ids.contains(&s.id)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// Only in the revised deck. + Inserted, + /// Only in the open deck. + Deleted, + /// In both, different. + Changed, +} + +/// One slide-level revision. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + /// Index in the open deck (for inserted: where it would go). + pub slide: usize, + /// Index in the revised deck (none for deleted). + pub revised: Option, + /// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`… + pub details: Vec, +} + +const MAX_DETAILS: usize = 50; + +/// Slide pairs (open index, revised index). +fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> { + if by_position { + return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect(); + } + let rev_ids: HashMap = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect(); + cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect() +} + +pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec { + let pairs = pairs(cur, rev, by_position); + let cur_matched: HashSet = pairs.iter().map(|p| p.0).collect(); + let rev_matched: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = vec![]; + for (i, j) in &pairs { + let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; + let details = slide_details(cur, a, rev, b); + if !details.is_empty() { + out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details }); + } + } + for (i, s) in cur.slides.iter().enumerate() { + if !cur_matched.contains(&i) { + out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] }); + } + } + for (j, s) in rev.slides.iter().enumerate() { + if !rev_matched.contains(&j) { + // After the open slide that matches the revised slide before it. + let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0); + out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] }); + } + } + out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0))); + out +} + +fn slide_title(s: &Slide) -> String { + let t = s + .shapes + .iter() + .find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle))) + .and_then(|x| x.text.as_ref()) + .map(|b| b.text()) + .unwrap_or_default(); + let t = t.trim(); + if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() } +} + +fn shape_label(s: &Shape) -> String { + if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } +} + +fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { + p.media(id).map(|m| m.data.as_slice()) +} + +/// A shape without its ids (shape and media), for comparing across decks. +fn plain_shape(s: &Shape) -> Shape { + let mut c = s.clone(); + fn rec(shapes: &mut [Shape]) { + for s in shapes { + s.id = ShapeId(0); + if let Some(ch) = s.children_mut() { + rec(ch); + } + } + } + rec(std::slice::from_mut(&mut c)); + c +} + +fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec) { + let label = shape_label(b); + let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); + let mut what = vec![]; + if text(a) != text(b) { + what.push("text"); + } else if a.text != b.text { + what.push("text formatting"); + } + match (&a.xfrm, &b.xfrm) { + (Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"), + (Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"), + (x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"), + _ => {} + } + if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { + what.push("formatting"); + } + let pic = |p: &Presentation, s: &Shape| match &s.kind { + ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), + _ => None, + }; + if pic(cp, a) != pic(rp, b) { + what.push("picture"); + } + if what.is_empty() { + let (mut x, mut y) = (plain_shape(a), plain_shape(b)); + x.text = None; + y.text = None; + if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) { + x.kind = ShapeKind::Shape; + y.kind = ShapeKind::Shape; + } + if x != y { + what.push("properties"); + } + } + if !what.is_empty() { + out.push(format!("{label}: {}", what.join(", "))); + } +} + +fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec { + let mut out = vec![]; + let mut used: HashSet = HashSet::new(); + for sa in &a.shapes { + let k = b + .shapes + .iter() + .position(|x| x.id == sa.id) + .or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); + match k { + Some(k) if !used.contains(&k) => { + used.insert(k); + if let Some(sb) = b.shapes.get(k) { + shape_details(cp, sa, rp, sb, &mut out); + } + } + _ => out.push(format!("{} deleted", shape_label(sa))), + } + } + for (k, sb) in b.shapes.iter().enumerate() { + if !used.contains(&k) { + out.push(format!("{} inserted", shape_label(sb))); + } + } + let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::>(); + if out.is_empty() && order(a) != order(b) { + out.push("order of objects".into()); + } + let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone()); + if layout_name(cp, a) != layout_name(rp, b) { + out.push("layout".into()); + } + if a.notes.text() != b.notes.text() { + out.push("notes".into()); + } + if a.transition != b.transition { + out.push("transition".into()); + } + if a.animations.len() != b.animations.len() + || a.animations + .iter() + .zip(&b.animations) + .any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms)) + { + out.push("animations".into()); + } + if a.background != b.background { + out.push("background".into()); + } + if a.hidden != b.hidden { + out.push(if b.hidden { "hidden" } else { "shown" }.into()); + } + out.truncate(MAX_DETAILS); + out +} + +fn max_shape_id(shapes: &[Shape], m: &mut u32) { + deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0)); +} + +/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape +/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its +/// layout matched by id, then by name, then the first layout. +pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option) -> Slide { + let mut sl = slide.clone(); + // Media. + let mut mids = vec![]; + crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids); + let mut media_map: HashMap = HashMap::new(); + for id in mids { + let Some(m) = rev.media(id) else { continue }; + match doc.media(id) { + Some(x) if x.data == m.data => {} + None => doc.media.push(m.clone()), + Some(_) => { + let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + media_map.insert(id, new); + } + } + } + // Shape ids used elsewhere in the open deck. + let mut taken: HashSet = HashSet::new(); + for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + taken.insert(x.id.0); + }); + } + let mut id_map: HashMap = HashMap::new(); + fn rec( + doc: &mut Presentation, + shapes: &mut [Shape], + taken: &HashSet, + ids: &mut HashMap, + media: &HashMap, + depth: usize, + ) { + if depth > 64 { + return; + } + for s in shapes { + if taken.contains(&s.id.0) { + let new = ShapeId(doc.alloc_id()); + ids.insert(s.id, new); + s.id = new; + } + let remap = |m: &mut MediaId| { + if let Some(n) = media.get(m) { + *m = *n; + } + }; + match &mut s.kind { + ShapeKind::Picture { fill } => remap(&mut fill.media), + ShapeKind::Media(m) => { + remap(&mut m.media); + if let Some(p) = m.poster.as_mut() { + remap(p); + } + } + _ => {} + } + if let Some(Fill::Picture(pf)) = &mut s.fill { + remap(&mut pf.media); + } + if let Some(ch) = s.children_mut() { + rec(doc, ch, taken, ids, media, depth + 1); + } + } + } + rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0); + for a in &mut sl.animations { + if let Some(n) = id_map.get(&a.shape) { + a.shape = *n; + } + if let Some(t) = a.trigger.as_mut() + && let Some(n) = id_map.get(t) + { + *t = *n; + } + } + let mut max = 0; + max_shape_id(&sl.shapes, &mut max); + doc.next_id = doc.next_id.max(max); + // Layout. + if doc.layout(sl.layout).is_none() { + let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone()); + let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id)); + sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default(); + } + match replacing { + Some(id) => sl.id = id, + None => { + if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 { + sl.id = SlideId(doc.alloc_id()); + } + doc.next_id = doc.next_id.max(sl.id.0); + } + } + sl +} + +/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show. +pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option { + match change.kind { + ChangeKind::Changed => { + let b = rev.slides.get(change.revised?)?.clone(); + let id = doc.slides.get(change.slide)?.id; + let sl = adopt(doc, rev, &b, Some(id)); + *doc.slides.get_mut(change.slide)? = Arc::new(sl); + Some(change.slide) + } + ChangeKind::Inserted => { + let b = rev.slides.get(change.revised?)?.clone(); + let sl = adopt(doc, rev, &b, None); + let at = change.slide.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(sl)); + Some(at) + } + ChangeKind::Deleted => { + if change.slide >= doc.slides.len() || doc.slides.len() <= 1 { + return None; + } + doc.slides.remove(change.slide); + Some(change.slide.min(doc.slides.len().saturating_sub(1))) + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + /// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title + /// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3. + fn decks() -> (Session, String, usize) { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + // Make the revision in a second document. + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0; + s.execute("edit.select", &json!({"ids": [first]})).unwrap(); + s.execute("text.edit", &json!({"id": first})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Revised title"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).ok(); + let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0; + s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok(); + s.execute("slide.go", &json!({"index": n - 2})).ok(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + (s, rev, n) + } + + fn kinds(v: &Value) -> Vec { + v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect() + } + + #[test] + fn lists_inserted_deleted_and_changed_slides() { + let (mut s, rev, n) = decks(); + let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let k = kinds(&v); + assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}"); + let ch = &v["changes"].as_array().unwrap()[0]; + assert_eq!(ch["slide"], 0); + assert!(ch["details"].to_string().contains("text"), "{ch}"); + let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap(); + assert_eq!(ins["details"][0], "Brand new"); + let at = ins["slide"].as_u64().unwrap() as usize; + assert_eq!(at, n, "after the open slide matching the revised one before it"); + assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); + } + + #[test] + fn accept_one_then_all_makes_the_decks_equal_and_undoes() { + let (mut s, rev, n) = decks(); + s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + s.execute("review.accept", &json!({"index": 0})).unwrap(); + let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + assert_eq!(after, before - 1); + assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title")))); + s.execute("review.accept", &json!({"all": true})).unwrap(); + let left = s.execute("review.revisions", &json!({})).unwrap(); + assert!(left["changes"].as_array().unwrap().is_empty(), "{left}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate()); + assert_eq!(s.doc().unwrap().doc.slides.len(), n); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before); + s.execute("review.endCompare", &json!({})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"all": true})).is_err()); + } + + #[test] + fn unrelated_decks_match_by_position_and_bad_input_fails() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap(); + let mut other = Session::with_new(); + other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap(); + other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap(); + assert!(kinds(&v).contains(&"inserted".into()), "{v}"); + s.execute("review.accept", &json!({"all": true})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty()); + assert!(s.doc().unwrap().doc.validate().is_empty()); + assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err()); + assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err()); + assert!(s.execute("review.compare", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"index": 999})).is_err()); + } +} diff --git a/crates/engine/src/gif_export.rs b/crates/engine/src/gif_export.rs new file mode 100644 index 00000000..da4f044f --- /dev/null +++ b/crates/engine/src/gif_export.rs @@ -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>, + /// 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, + 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 { + 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 { + 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 { + 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::>>() + .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> { + let n = doc.slides.len(); + let chosen: Vec = 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 = 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 { + 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 { + 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 { + 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) { + 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 { + 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); + } +} diff --git a/crates/engine/src/inkshape.rs b/crates/engine/src/inkshape.rs new file mode 100644 index 00000000..593bcbc1 --- /dev/null +++ b/crates/engine/src/inkshape.rs @@ -0,0 +1,192 @@ +//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle. +//! +//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends +//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1, +//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the +//! centre. + +/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Recognized { + /// From `(x0, y0)` to `(x1, y1)`. + Line { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + }, + Rect { + x: f64, + y: f64, + w: f64, + h: f64, + }, + Ellipse { + x: f64, + y: f64, + w: f64, + h: f64, + }, + /// Apex at the top (`flip_v` false) or at the bottom. + Triangle { + x: f64, + y: f64, + w: f64, + h: f64, + flip_v: bool, + }, +} + +/// Strokes smaller than this (points) stay ink. +const MIN_SIZE: f64 = 6.0; +const MAX_POINTS: usize = 100_000; + +pub fn recognize(points: &[(f64, f64)]) -> Option { + let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect(); + if pts.len() < 2 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in &pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let (w, h) = (x1 - x0, y1 - y0); + let size = w.max(h); + if !size.is_finite() || size < MIN_SIZE { + return None; + } + let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum(); + let (first, last) = (pts[0], pts[pts.len() - 1]); + let gap = (last.0 - first.0).hypot(last.1 - first.1); + if length <= 0.0 { + return None; + } + // Open stroke: a line if nearly straight, otherwise not a shape. + if gap > 0.25 * size { + return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 }); + } + // Closed but flat: a line drawn back and forth isn't a shape. + if w.min(h) < 0.15 * size { + return None; + } + // Covered area (shoelace, the gap closed) over the bounding box. + let mut a2 = 0.0; + for k in 0..pts.len() { + let (p, q) = (pts[k], pts[(k + 1) % pts.len()]); + a2 += p.0 * q.1 - q.0 * p.1; + } + let cover = (a2.abs() / 2.0) / (w * h); + // Ellipse fit: distance from the centre in box-normalised units should stay near 1. + let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0); + let radial: Vec = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect(); + let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::() / radial.len() as f64; + if cover > 0.86 { + Some(Recognized::Rect { x: x0, y: y0, w, h }) + } else if cover > 0.66 && spread < 0.12 { + Some(Recognized::Ellipse { x: x0, y: y0, w, h }) + } else if (0.35..0.66).contains(&cover) { + // Apex: the side of the box with fewer points near it is where the triangle narrows. + let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count(); + let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count(); + Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> { + let mut v = vec![]; + for k in 0..corners.len() { + let (a, b) = (corners[k], corners[(k + 1) % corners.len()]); + for i in 0..per_side { + let t = i as f64 / per_side as f64; + // A little hand wobble. + let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8; + v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob)); + } + } + v.push((corners[0].0 + 2.0, corners[0].1 + 1.5)); + v + } + + #[test] + fn rectangles_ellipses_triangles_and_lines() { + let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap(); + assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}"); + let ell: Vec<(f64, f64)> = (0..=80) + .map(|i| { + let a = i as f64 / 80.0 * std::f64::consts::TAU; + (200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin()) + }) + .collect(); + assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell)); + let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap(); + assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}"); + let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap(); + assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}"); + let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect(); + assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line)); + } + + #[test] + fn scribbles_dots_and_garbage_stay_ink() { + assert_eq!(recognize(&[]), None); + assert_eq!(recognize(&[(1.0, 1.0)]), None); + assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small"); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect(); + assert_eq!(recognize(&zig), None, "an open zigzag is no line"); + assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None); + let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect(); + assert_eq!(recognize(&there_and_back), None); + } + + #[test] + fn ink_to_shape_command_converts_and_undoes() { + use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind}; + use serde_json::json; + let mut s = crate::Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect(); + let mk = |v: &[(f64, f64)]| InkStroke { + points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(), + color: deckcraft_color::Rgba::rgb(200, 0, 0), + width: 3.0, + highlighter: false, + }; + let strokes = vec![mk(&rect), mk(&zig)]; + s.edit(|doc, sel| { + let id = ShapeId(doc.alloc_id()); + crate::shapes_mut(doc, sel).unwrap().push(Shape { + id, + name: "Ink".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { strokes }, + ..Default::default() + }); + Ok(serde_json::Value::Null) + }) + .unwrap(); + let n0 = s.doc().unwrap().shapes().len(); + let r = s.execute("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["kept"], 1); + let sh = s.doc().unwrap().shapes(); + assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added"); + let made = sh.last().unwrap(); + assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect")); + assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), n0); + // Nothing recognisable: a no-op, not an error. Empty slide too. + s.execute("draw.inkToShape", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index aaba8d42..c19efc0f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -23,10 +23,31 @@ pub fn read_file(path: &str) -> Result, String> { /// `path` is. #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); crate::file_access::check_write(path)?; - let tmp = format!("{path}.saving"); - std::fs::write(&tmp, bytes).map_err(|e| format!("{path}: {e}"))?; - std::fs::rename(&tmp, path).map_err(|e| format!("{path}: {e}")) + let err = |e: std::io::Error| format!("{path}: {e}"); + // A uniquely named temporary sibling, created exclusively (never following or reusing an + // existing file or symlink), is renamed over the destination: 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")] diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index bc71b575..38b5aa71 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -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!({})) } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 695d6ab3..340aed02 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,12 +8,15 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod compare; pub mod connect; pub mod drawtable; pub mod file_access; pub mod fonts; +pub mod gif_export; pub mod guard; pub mod icons; +pub mod inkshape; pub mod io; pub mod keys; pub mod links; @@ -22,6 +25,7 @@ pub mod sample; pub mod sounds; pub mod spell; pub mod templates; +pub mod thesaurus; pub mod tools; use std::sync::Arc; @@ -215,6 +219,8 @@ pub struct DocState { pub missing_fonts: Vec, /// An input method's composition in progress ([`Composition`]). pub composition: Option, + /// Review › Compare in progress: the revised copy. + pub compare: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -241,6 +247,7 @@ impl DocState { title, missing_fonts: Vec::new(), composition: None, + compare: None, } } pub fn is_dirty(&self) -> bool { @@ -355,6 +362,29 @@ impl Default for Prefs { } } +impl Prefs { + /// The preferences with every value in a range the editor can use: `prefs.json` is a file + /// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo + /// limit) must not break editing or exhaust memory. Out-of-range numbers take their + /// defaults or the nearest usable value. + pub fn sanitized(mut self) -> Prefs { + let d = Prefs::default(); + let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.grid_spacing = + if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing }; + self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset); + self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT); + self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes); + if self.author.chars().count() > 256 { + self.author = self.author.chars().take(256).collect(); + } + self + } +} + +/// The most undo steps [`Prefs::undo_limit`] can keep. +pub const MAX_UNDO_LIMIT: usize = 10_000; + /// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 7bcc98c9..707d0aad 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -84,6 +84,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display()))) } +/// Presentations reopened per start; the rest stay on disk for the next one, so a folder +/// full of entries can't stall start-up. +pub const MAX_REOPEN: usize = 32; +/// Largest entry read back. +const MAX_ENTRY_BYTES: u64 = 2 << 30; +const MAX_META_BYTES: u64 = 64 << 10; + +/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an +/// entry belongs would block start-up forever. +fn read_regular(p: &Path, max: u64) -> Option> { + let m = std::fs::metadata(p).ok()?; + (m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten() +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json"))) } @@ -136,11 +150,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") { continue; } + if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) { + continue; + } let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. - let meta = std::fs::read_to_string(p.with_extension("json")) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) + let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES) + .and_then(|t| serde_json::from_slice::(&t).ok()) .filter(Value::is_object) .unwrap_or(json!({})); out.push((uid, meta)); @@ -153,11 +169,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { /// Entries stay on disk, now owned by this instance: the presentation keeps the entry's uid, so /// the next recovery save replaces the snapshot and saving or closing it removes it. Entries of a /// running instance, unreadable entries and entries already open here are left alone. Returns the -/// opened documents' indices. +/// opened documents' indices. At most [`MAX_REOPEN`] are opened per start, the newest first; the rest +/// wait for the next one. pub fn open(s: &mut Session, dir: &Path) -> Result> { let me = instance_id(); let mut opened = Vec::new(); - for (uid, mut meta) in list(dir) { + let mut entries = list(dir); + entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid))); + for (uid, mut meta) in entries { + if opened.len() >= MAX_REOPEN { + break; + } if s.documents().iter().any(|d| d.uid == uid) { continue; } @@ -168,7 +190,7 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { continue; } let (doc, meta_path) = files(dir, uid); - let Ok(bytes) = std::fs::read(&doc) else { continue }; + let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue }; let Ok(mut d) = deckcraft_format::load(&bytes) else { continue }; deckcraft_format::repair(&mut d); let path = meta.get("path").and_then(Value::as_str).map(str::to_string); @@ -311,4 +333,36 @@ mod tests { assert_eq!(v.as_array().map(Vec::len), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + + /// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked + /// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start. + #[cfg(unix)] + #[test] + fn hostile_recovery_folders_dont_stall_start_up() { + let dir = temp("recovery-hostile"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + save(&s, &dir).unwrap(); + let (good, _) = files(&dir, s.doc().unwrap().uid); + for i in 1..=(MAX_REOPEN as u64 + 3) { + std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap(); + } + for name in ["5.deckcraft", "6.json"] { + let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status(); + } + std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let d = dir.clone(); + std::thread::spawn(move || { + let mut s2 = Session::new(); + let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0)); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung"); + assert_eq!(n, MAX_REOPEN); + // Every entry stays on disk (main keeps snapshots until a newer save); the 32 opened plus + // the rest that wait for the next start. + assert_eq!(list(&dir).len(), MAX_REOPEN + 5, "the rest wait for the next start"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f18d172..4bb0a6d9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -141,6 +141,62 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +#[test] +fn bold_copy_is_html_and_pages_html_pastes_bold() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + let v = s.execute("edit.copy", &json!({})).unwrap(); + let html = v["html"].as_str().unwrap(); + assert!(html.contains(""), "{html}"); + assert_eq!(v["text"], "Hello"); + // The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed. + s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap(); + s.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap(); + assert_eq!(body.text(), "HelloHello"); + assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}"); + s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap(); + let caret = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(caret["text"], ""); + assert_eq!(caret["html"], ""); + + let mut ext = session(); + ext.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let pasted = ext + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello")) + .unwrap() + .text + .clone() + .unwrap(); + assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}"); + + let mut mismatched = session(); + mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "Nope"})).unwrap(); + let plain = mismatched + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .unwrap() + .text + .clone() + .unwrap(); + assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true)); +} + #[test] fn group_ungroup_roundtrip_positions() { let mut s = session(); @@ -174,7 +230,7 @@ fn align_and_distribute() { fn pointer_draw_move_resize() { let mut s = session(); s.set_tool(ToolKind::Shape { preset: "rect".into() }); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap(); let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap(); @@ -205,7 +261,7 @@ fn pointer_draw_move_resize() { #[test] fn click_into_placeholder_starts_editing() { let mut s = session(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap(); s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); @@ -237,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}"); } } @@ -287,6 +344,34 @@ fn base64_roundtrip() { assert!(cmd::base64_decode("!!").is_none()); } +#[test] +fn new_slide_body_tabs_match_text_set() { + let body = "Install from DMG\nLaunch app\n\tCheck signing\n\t\tDeep\nBuild a deck"; + let mut s = session(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Test Plan", "body": body})).unwrap(); + let levels = |s: &Session| { + let slide = s.doc().unwrap().current_slide().unwrap(); + let sh = slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap(); + let rows: Vec<(u8, String)> = sh.text.as_ref().unwrap().paragraphs.iter().map(|p| (p.level, p.text())).collect(); + (sh.id.0, rows) + }; + let (id, from_new) = levels(&s); + assert_eq!( + from_new, + vec![ + (0, "Install from DMG".to_string()), + (0, "Launch app".to_string()), + (1, "Check signing".to_string()), + (2, "Deep".to_string()), + (0, "Build a deck".to_string()), + ] + ); + s.execute("text.set", &json!({"id": id, "text": body})).unwrap(); + assert_eq!(levels(&s).1, from_new); + s.execute("text.set", &json!({"id": id, "text": "\t\t\t\t\t\t\t\t\tCapped"})).unwrap(); + assert_eq!(levels(&s).1, vec![(8, "Capped".to_string())]); +} + #[test] fn layout_change_keeps_text() { let mut s = session(); @@ -356,7 +441,7 @@ fn drawing_a_line_between_shapes_glues_it() { let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.deselect", &json!({})).unwrap(); s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() }; - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None }; // Right site of the ellipse (200,150) → left site of the rect (400,150), a little off. s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap(); s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap(); @@ -389,7 +474,7 @@ fn freeform_tools_and_command() { s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().shapes().len(), before + 1); - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None }; // Polygon: click, click, click, click on the start → closed, filled. s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] { @@ -442,7 +527,7 @@ fn double_click_in_table_uses_grown_row_heights() { for row in 0..3 { s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap(); } - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0))); } @@ -457,7 +542,7 @@ fn double_click_edits_text_inside_group() { let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [300.0, 200.0, 50.0, 50.0]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); s.execute("arrange.group", &json!({})).unwrap(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // Double-click the grouped text box: it becomes editable and targets that member. s.pointer(ev(PointerKind::DoubleClick, 160.0, 125.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(a)); @@ -483,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 { + 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::(); + 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(); @@ -753,7 +1132,7 @@ fn shapes_off_the_slide_can_be_selected_and_moved() { // Inserting off the slide keeps the box where it was asked for. assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0); s.execute("edit.select", &json!({"ids": []})).unwrap(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // Hover and click wholly off the slide. assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off)); s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); @@ -871,9 +1250,9 @@ fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); s.compose_text("녕", None).unwrap(); - s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); - s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); assert_eq!(title_text(&s), "안녕"); @@ -1119,7 +1498,7 @@ fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm { } fn right_click(s: &mut Session, x: f64, y: f64) -> serde_json::Value { - s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0 }).unwrap() + s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap() } /// A blank slide with a rectangle A (100,100 200×100) and a text box B (400,100 200 wide), @@ -1150,7 +1529,7 @@ fn right_click_selects_the_shape_under_the_pointer() { assert_eq!(st.selection.shapes, vec![a]); assert!(st.selection.text.is_none()); assert!(!s.tool.dragging()); - s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); assert_eq!(text_box_xfrm(&s, a.0 as u64).x, 100.0); // A text box, which a left click would start editing: only selected. right_click(&mut s, 450.0, 115.0); @@ -2335,6 +2714,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(); @@ -2525,3 +3204,171 @@ fn the_caret_takes_the_colour_of_the_text() { let red = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap(); assert_eq!((red.r, red.g, red.b), (0xFF, 0, 0)); } + +/// Smooth Corners (#286): a rounded rectangle becomes a squircle outline that survives PPTX. +#[test] +fn smooth_corners_makes_a_squircle_outline() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("shape.smoothCorners", &json!({"smoothing": 60})).unwrap(); + let d = |s: &Session| match &s.doc().unwrap().shape(id).unwrap().geom { + deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(), + g => panic!("{g:?}"), + }; + // The default roundRect radius is 16.667% of the short side: 16.667 pt, smoothed 60%. + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6)); + // The smoothed outline takes a new smoothing with the radius it was drawn with; without a + // radius, or with another one (it could be any freeform), it is refused. + s.execute("shape.smoothCorners", &json!({"smoothing": 100, "radius": 16.667})).unwrap(); + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 1.0)); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err()); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50, "radius": 30})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6)); + // Other shapes are refused. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [0, 0, 50, 50]})).unwrap(); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err()); + // So is a freeform, whatever radius is given. + s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + s.execute("shape.editPoints", &json!({"on": false})).unwrap(); + assert!(matches!(s.doc().unwrap().shapes().last().unwrap().geom, deckcraft_model::Geom::Custom { .. }), "Edit Points made a freeform"); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50, "radius": 8})).is_err()); + // The outline round-trips through PPTX as custom geometry. + let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + let back = crate::cmd::file::open_presentation("x.pptx", &bytes).unwrap(); + let sh = back.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. })); + assert!(sh.is_some(), "a custom outline in the saved file"); +} + +#[test] +fn wordart_adjust_handle_drags_and_undoes() { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let id = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap(); + let sid = model::ShapeId(id as u32); + { + let st = s.doc_mut().unwrap(); + let slide = st.selection.slide; + let mut doc = (*st.doc).clone(); + let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap(); + sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into()); + st.doc = std::sync::Arc::new(doc); + } + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let (at, top) = { + let st = s.doc().unwrap(); + let sh = st.shape(sid).unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, sh); + let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h); + assert_eq!(hs.len(), 1); + let at = x.affine() * hs[0].pos; + (at, deckcraft_geom::Point::new(x.center().x, x.y)) + }; + assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. }))); + let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0, pressure: None }; + s.pointer(ev(PointerKind::Down, at)).unwrap(); + s.pointer(ev(PointerKind::Drag, top)).unwrap(); + s.pointer(ev(PointerKind::Up, top)).unwrap(); + let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone(); + let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0; + assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body; + assert_eq!(body.warp.as_deref(), Some("textArchUp")); + assert!(body.warp_adj.is_empty(), "undo restores the default adjusts"); +} + +#[test] +fn shape_picture_fill_options_tile_offset_and_round_trip() { + use deckcraft_model::style::PictureMode; + let mut img = image::RgbaImage::new(4, 4); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let pf = |s: &Session| match s.doc().unwrap().selected_shapes()[0].fill.clone() { + Some(deckcraft_model::Fill::Picture(pf)) => pf, + other => panic!("{other:?}"), + }; + // Options without a picture fill are a no-op, not an error. + s.execute("shape.fill", &json!({"pictureProps": {"tile": true}})).unwrap(); + s.execute("shape.fill", &json!({"picture": cmd::base64_encode(&png)})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"stretch": [0.1, 0.2, 5, "x"]}})).unwrap(); + assert_eq!(pf(&s).mode, PictureMode::Stretch { fill_rect: [0.1, 0.2, 0.99, 0.0] }); + s.execute("shape.fill", &json!({"pictureProps": {"tile": true, "offsetX": 12, "offsetY": -3, "scaleX": 0.5, "scaleY": 50, "align": "ctr", "mirror": "xy", "rotateWithShape": false}})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"align": "bogus", "offsetX": f64::NAN}, "transparency": 0.4})).unwrap(); + let want = PictureMode::Tile { tx: 12.0, ty: -3.0, sx: 0.5, sy: 10.0, flip: "xy".into(), align: "ctr".into() }; + let got = pf(&s); + assert_eq!(got.mode, want); + assert!(got.upright); + assert!((got.alpha.unwrap() - 0.6).abs() < 1e-9); + // PPTX round trip keeps the options. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "p.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let doc = &s.doc().unwrap().doc; + let back = doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.fill { + Some(deckcraft_model::Fill::Picture(pf)) => Some(pf.clone()), + _ => None, + }); + let back = back.unwrap(); + assert_eq!(back.mode, want); + assert!(back.upright); + assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6); +} + +#[test] +fn find_and_replace_across_shapes_and_notes() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old plan, old team\nboldly"})).unwrap(); + s.execute("slide.notes", &json!({"text": "Discuss the old plan"})).unwrap(); + let q = json!({"text": "old"}); + let found = s.execute("text.find", &q).unwrap(); + let n = found.as_array().unwrap().len(); + assert!(n >= 3, "{found}"); + let whole = s.execute("text.find", &json!({"text": "old", "wholeWords": true, "matchCase": true})).unwrap(); + assert_eq!(whole.as_array().unwrap().len(), n - 2, "Old and boldly are not whole lower-case matches"); + + let first = found[0].clone(); + assert_eq!((first["para"].as_u64(), first["start"].as_u64()), (Some(0), Some(0))); + s.execute("text.replace", &json!({"text": "old", "with": "New", "at": first})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); + + let r = s.execute("text.replace", &json!({"text": "old", "with": "new", "wholeWords": true})).unwrap(); + assert_eq!(r["replaced"].as_u64(), Some(n as u64 - 2)); + let text = s.doc().unwrap().doc.slides[0].shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::>().join("|"); + assert!(text.contains("New plan, new team\nboldly"), "{text}"); + assert!(s.execute("text.replace", &json!({"text": "zebra", "with": "x"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); +} + +#[test] +fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { + let mut s = session(); + s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 }); + let ev = |kind, x, y, pressure| tools::PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure }; + s.pointer(ev(PointerKind::Down, 40.0, 80.0, Some(0.2))).unwrap(); + s.pointer(ev(PointerKind::Drag, 80.0, 80.0, Some(1.0))).unwrap(); + s.pointer(ev(PointerKind::Drag, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Up, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Down, 40.0, 160.0, None)).unwrap(); + s.pointer(ev(PointerKind::Drag, 100.0, 160.0, Some(f32::NAN))).unwrap(); + s.pointer(ev(PointerKind::Up, 100.0, 160.0, None)).unwrap(); + let got = ink_pressures(&s); + assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]); +} + +fn ink_pressures(s: &Session) -> Vec> { + let st = s.doc().unwrap(); + let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { + return vec![]; + }; + let deckcraft_model::ShapeKind::Ink { strokes } = &sh.kind else { return vec![] }; + strokes.iter().map(|stroke| stroke.points.iter().map(|p| p.2).collect()).collect() +} diff --git a/crates/engine/src/thesaurus.rs b/crates/engine/src/thesaurus.rs new file mode 100644 index 00000000..10cc207d --- /dev/null +++ b/crates/engine/src/thesaurus.rs @@ -0,0 +1,119 @@ +//! Review › Thesaurus: synonyms from a small built-in English word list (`data/thesaurus.txt`), +//! looked up offline so it works in the browser build too. + +const DATA: &str = include_str!("../data/thesaurus.txt"); + +/// One sense of a word: its headword and synonyms. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Sense { + pub word: String, + pub synonyms: Vec, +} + +fn senses_of(head: &str) -> Vec { + DATA.lines() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split_once('|')) + .filter(|(h, _)| h.trim() == head) + .map(|(h, s)| Sense { word: h.trim().to_string(), synonyms: s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() }) + .collect() +} + +/// The senses for `word`, trying simple English endings when the word itself isn't listed +/// ("goals" → "goal", "improved" → "improve", "growing" → "grow"). Synonyms take the word's +/// capitalisation ("Goal" → "Aim", "GOAL" → "AIM"). +pub fn lookup(word: &str) -> Vec { + let w: String = word.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != '-').chars().take(64).collect(); + let lower = w.to_lowercase(); + if lower.is_empty() { + return vec![]; + } + let mut tries = vec![lower.clone()]; + for (suffix, add) in [("ies", "y"), ("es", ""), ("s", ""), ("ied", "y"), ("ed", "e"), ("ed", ""), ("ing", "e"), ("ing", "")] { + if let Some(stem) = lower.strip_suffix(suffix) + && stem.chars().count() >= 2 + { + tries.push(format!("{stem}{add}")); + } + } + let Some(found) = tries.iter().map(|t| senses_of(t)).find(|v| !v.is_empty()) else { return vec![] }; + let upper = w.chars().count() > 1 && w.chars().all(|c| !c.is_lowercase()); + let title = !upper && w.chars().next().is_some_and(char::is_uppercase); + found + .into_iter() + .map(|s| Sense { + word: s.word, + synonyms: s + .synonyms + .into_iter() + .map(|x| { + if upper { + x.to_uppercase() + } else if title { + let mut c = x.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + x + } + }) + .collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn senses_endings_and_case() { + let g = lookup("goal"); + assert_eq!(g.len(), 1); + assert!(g[0].synonyms.contains(&"objective".to_string())); + assert_eq!(lookup("change").len(), 2, "verb and noun senses"); + assert_eq!(lookup("Goals")[0].synonyms[0], "Aim"); + assert_eq!(lookup("GOAL")[0].synonyms[0], "AIM"); + assert_eq!(lookup("improved")[0].word, "improve"); + assert_eq!(lookup("growing")[0].word, "grow"); + assert_eq!(lookup("strategies")[0].word, "strategy"); + assert_eq!(lookup("“goal,”")[0].word, "goal", "punctuation around the word"); + assert!(lookup("").is_empty() && lookup("zzxq").is_empty() && lookup(" ").is_empty()); + assert!(lookup(&"a".repeat(10_000)).is_empty()); + } + + #[test] + fn data_is_well_formed() { + for l in DATA.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let (h, s) = l.split_once('|').unwrap_or_else(|| panic!("no `|`: {l}")); + assert_eq!(h, h.trim().to_lowercase(), "{l}"); + let syn: Vec<&str> = s.split(',').map(str::trim).collect(); + assert!(syn.len() >= 2 && syn.iter().all(|x| !x.is_empty() && *x != h), "{l}"); + let mut d = syn.clone(); + d.sort(); + d.dedup(); + assert_eq!(d.len(), syn.len(), "duplicate synonym: {l}"); + } + } + + #[test] + fn commands_look_up_and_replace_the_word_at_the_caret() { + use serde_json::json; + let mut s = crate::Session::with_new(); + let id = s.execute("insert.textBox", &json!({"rect": [50, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let r = s.execute("review.thesaurus", &json!({})).unwrap(); + assert_eq!(r["word"], "Goal"); + assert_eq!(r["senses"][0]["synonyms"][0], "Aim"); + s.execute("review.replaceWord", &json!({"with": "Aim"})).unwrap(); + let text = |s: &crate::Session| { + s.doc().unwrap().shapes().iter().find(|x| x.id.0 as u64 == id).and_then(|x| x.text.as_ref()).map(|t| t.paragraphs[0].text()).unwrap() + }; + assert_eq!(text(&s), "Our Aim is growth."); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s), "Our Goal is growth."); + assert_eq!(s.execute("review.thesaurus", &json!({"word": "growth."})).unwrap()["senses"][0]["word"], "growth"); + assert!(s.execute("review.replaceWord", &json!({"with": ""})).is_err()); + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 652232c5..dbc72dcb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -43,6 +43,10 @@ pub struct PointerEvent { pub mods: Mods, /// Hit tolerance in points (≈ 4 screen px at the current zoom). pub tol: f64, + /// Pen pressure `0..=1` when the device reported one. `None` is a mouse: the stroke + /// is stored at full pen width. + #[serde(default)] + pub pressure: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -92,6 +96,11 @@ pub enum Hit { path: usize, index: usize, }, + /// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`. + Warp { + id: ShapeId, + index: usize, + }, /// Line end 0/1 of a line-like shape. LineEnd { id: ShapeId, @@ -101,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)] @@ -146,16 +170,40 @@ enum Drag { index: usize, out: bool, }, + /// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults). + Warp { + id: ShapeId, + index: usize, + name: String, + base: Vec, + orig: Xfrm, + }, Marquee { start: Point, add: bool, }, + /// Dragging drawing guide `index`. + Guide { + index: usize, + }, Create { start: Point, }, 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)>, }, @@ -215,6 +263,14 @@ pub struct ToolState { pub edit_vertex: Option<(usize, usize)>, /// The point type last chosen for a vertex (path, index, type): handle drags follow it. pub vertex_kind: Option<(usize, usize, String)>, + /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. + pub guides_visible: bool, + /// The guide being dragged and where it is now (slide points); `None` once dropped. + pub guide_drag: Option<(usize, f64)>, + /// The drawing guide under the pointer, for the cursor. + pub guide_hover: Option, + /// Pressure from the pointer event being handled, if the device reported one. + pressure: Option, } impl ToolState { @@ -246,6 +302,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) { (pts, rot) } +/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the +/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on. +pub fn wordart_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + w: f64, + h: f64, +) -> Vec { + if sh.text.is_none() { + return Vec::new(); + } + let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() }; + let bp = deckcraft_model::resolve::body(&ctx, sh); + let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() }; + let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect; + deckcraft_geom::warp::handles(name, &bp.warp_adj, tr) +} + +/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults). +fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) { + let Some(text) = sh.text.as_mut() else { return }; + if !value.is_finite() || index > 7 { + return; + } + let defaults = deckcraft_geom::warp::adjust_defaults(name); + let n = defaults.len().max(index + 1).min(8); + if n == 0 { + return; + } + let mut adj = Vec::with_capacity(n); + for i in 0..n { + let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) }; + adj.push(v); + } + text.body.warp = Some(name.to_string()); + text.body.warp_adj = adj; +} + impl Session { pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; @@ -298,6 +393,11 @@ impl Session { return Some(Hit::Adjust { id: *id, index: hd.index }); } } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } let (pts, rot) = box_handles(&x); if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { return Some(Hit::Rotate { id: *id }); @@ -309,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() { @@ -326,6 +433,7 @@ impl Session { pub fn pointer(&mut self, ev: PointerEvent) -> Result { let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; let p = Point::new(ev.x, ev.y); + self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); if !(p.x.is_finite() && p.y.is_finite()) { return Ok(Value::Null); } @@ -345,6 +453,8 @@ impl Session { return Ok(Value::Null); } self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); if line_tool { self.update_sites(p, tol, None); @@ -369,7 +479,7 @@ impl Session { } } - fn begin(&mut self, label: &str) { + pub(crate) fn begin(&mut self, label: &str) { if let Some(st) = self.active_mut() && st.interaction.is_none() { @@ -386,6 +496,14 @@ impl Session { } } + /// End the interaction only when it is the one `label` started. A twist must not commit a drag. + pub(crate) fn commit_named(&mut self, label: &str) { + let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); + if ours { + self.commit(); + } + } + fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { // Next click of a freeform: close on the start point, else add a vertex. @@ -419,36 +537,17 @@ impl Session { if mode == "eraser" { return self.erase_at(p, tol); } - self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] }); - self.tool.ink_preview = vec![(p.x, p.y, 0.5)]; + let sample = self.ink_sample(p); + self.tool.drag = Some(Drag::Ink { points: vec![sample] }); + self.tool.ink_preview = vec![sample]; return Ok(Value::Null); } 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 { .. })) - { - 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; @@ -482,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"); @@ -507,6 +608,29 @@ 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| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -527,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; @@ -546,6 +672,14 @@ impl Session { let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); } } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } None => { let add = mods.shift || mods.cmd; self.select(|_, sel| { @@ -565,6 +699,13 @@ impl Session { fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { @@ -709,6 +850,23 @@ impl Session { } return Ok(Value::Null); } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } @@ -745,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; @@ -755,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) { @@ -768,7 +947,7 @@ impl Session { self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); } Drag::Ink { mut points } => { - points.push((p.x, p.y, 0.5)); + points.push(self.ink_sample(p)); self.tool.ink_preview = points.clone(); self.tool.drag = Some(Drag::Ink { points }); } @@ -797,12 +976,21 @@ impl Session { self.tool.glue = None; let mut out = Value::Null; match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } 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}); } } @@ -932,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) { @@ -941,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 { .. } => { @@ -1005,7 +1191,11 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. } + | Hit::Warp { id, .. }, ) => Some(*id), None => None, }; @@ -1024,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> { + 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 { + 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(_))) } @@ -1140,6 +1420,22 @@ impl Session { }) } + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { @@ -1158,6 +1454,15 @@ impl Session { (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), (size.height, Rect::new(0.0, size.height, size.width, size.height)), ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } for sh in st.shapes() { if exclude.contains(&sh.id) || sh.hidden { continue; @@ -1238,6 +1543,15 @@ impl Session { nx } + /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. + fn ink_sample(&self, p: Point) -> (f64, f64, f32) { + let pressure = match self.tool.pressure { + Some(v) if v.is_finite() => v.clamp(0.0, 1.0), + _ => 1.0, + }; + (p.x, p.y, pressure) + } + fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; @@ -1328,6 +1642,46 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option Option { + 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 } @@ -1441,6 +1795,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option= -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. @@ -1509,18 +1872,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option (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. @@ -1528,29 +1880,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec 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::*; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 68c955df..44d9395f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -459,6 +459,22 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { .collect() } +/// Folder depth below a font folder that the system scan descends. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_DEPTH: usize = 12; +/// Directory entries the system scan looks at in all. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_ENTRIES: usize = 200_000; +/// The largest font file read (the biggest CJK collections are a few hundred MB). +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_FONT_FILE: u64 = 512 << 20; + +/// A regular file of a size a font can have. +#[cfg(not(target_arch = "wasm32"))] +fn font_file_ok(meta: &std::fs::Metadata) -> bool { + meta.is_file() && meta.len() > 12 && meta.len() <= MAX_FONT_FILE +} + /// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. /// Empty on wasm. pub fn system_font_dirs() -> Vec { @@ -917,24 +933,41 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut found = Vec::new(); - let mut stack = self.font_dirs.clone(); + let mut stack: Vec<(std::path::PathBuf, usize)> = self.font_dirs.iter().map(|d| (d.clone(), 0)).collect(); // Each folder once, however links lead back to it. let mut visited = std::collections::HashSet::new(); - while let Some(d) = stack.pop() { + let mut entries = 0usize; + while let Some((d, depth)) = stack.pop() { if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { continue; } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { + // A link to `/` or a vast tree mustn't turn start-up into a disk crawl. + entries += 1; + if entries > MAX_SCAN_ENTRIES { + log::warn!("font scan stopped after {MAX_SCAN_ENTRIES} entries"); + stack.clear(); + break; + } let p = e.path(); - if p.is_dir() { - stack.push(p); + // Follows links: what matters is what the entry leads to. + let Ok(meta) = std::fs::metadata(&p) else { continue }; + if meta.is_dir() { + if depth < MAX_SCAN_DEPTH { + stack.push((p, depth + 1)); + } continue; } let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } + // Only regular files: opening a FIFO named like a font blocks until something + // writes to it (start-up hung), and a device never ends. + if !font_file_ok(&meta) { + continue; + } for (family, style) in file_face_names(&p) { found.push(CatalogEntry { family, style, path: p.clone() }); } @@ -966,7 +999,7 @@ impl FontDb { p.dedup(); p }; - self.add_fonts(paths.iter().filter_map(|p| std::fs::read(p).ok()).collect()); + self.add_fonts(paths.iter().filter(|p| std::fs::metadata(p).is_ok_and(|m| font_file_ok(&m))).filter_map(|p| std::fs::read(p).ok()).collect()); self.is_loaded(family) } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 1bc88065..a68fd986 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -419,4 +419,30 @@ mod tests { truncated.truncate(40); assert!(embedded_sfnt(&truncated).is_none()); } + + /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them + /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and + /// still finds the real font beside them. + #[cfg(unix)] + #[test] + fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/format/src/finite.rs b/crates/format/src/finite.rs new file mode 100644 index 00000000..834108a3 --- /dev/null +++ b/crates/format/src/finite.rs @@ -0,0 +1,185 @@ +//! A serde serializer adapter that writes non-finite floats as 0. +//! +//! serde_json writes NaN and ±infinity as `null`, which doesn't read back as a number: a single +//! NaN anywhere in the model (a rotation, a crop, a stroke width) made a saved file fail to open +//! as "damaged". [`Finite`] wraps any `Serialize` value and replaces those floats on the way out; +//! everything else passes through unchanged. + +use serde::ser::{self, Serialize, Serializer}; + +/// `T`, serialized with non-finite floats written as 0. +pub struct Finite<'a, T: ?Sized>(pub &'a T); + +impl Serialize for Finite<'_, T> { + fn serialize(&self, s: S) -> Result { + self.0.serialize(Wrap(s)) + } +} + +struct Wrap(S); + +macro_rules! pass { + ($($m:ident($t:ty)),* $(,)?) => { + $(fn $m(self, v: $t) -> Result { self.0.$m(v) })* + }; +} + +impl Serializer for Wrap { + type Ok = S::Ok; + type Error = S::Error; + type SerializeSeq = Wrap; + type SerializeTuple = Wrap; + type SerializeTupleStruct = Wrap; + type SerializeTupleVariant = Wrap; + type SerializeMap = Wrap; + type SerializeStruct = Wrap; + type SerializeStructVariant = Wrap; + + pass!( + serialize_bool(bool), + serialize_i8(i8), + serialize_i16(i16), + serialize_i32(i32), + serialize_i64(i64), + serialize_i128(i128), + serialize_u8(u8), + serialize_u16(u16), + serialize_u32(u32), + serialize_u64(u64), + serialize_u128(u128), + serialize_char(char), + serialize_str(&str), + serialize_bytes(&[u8]), + ); + + fn serialize_f32(self, v: f32) -> Result { + self.0.serialize_f32(if v.is_finite() { v } else { 0.0 }) + } + fn serialize_f64(self, v: f64) -> Result { + self.0.serialize_f64(if v.is_finite() { v } else { 0.0 }) + } + fn serialize_none(self) -> Result { + self.0.serialize_none() + } + fn serialize_some(self, v: &T) -> Result { + self.0.serialize_some(&Finite(v)) + } + fn serialize_unit(self) -> Result { + self.0.serialize_unit() + } + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.0.serialize_unit_struct(name) + } + fn serialize_unit_variant(self, name: &'static str, i: u32, variant: &'static str) -> Result { + self.0.serialize_unit_variant(name, i, variant) + } + fn serialize_newtype_struct(self, name: &'static str, v: &T) -> Result { + self.0.serialize_newtype_struct(name, &Finite(v)) + } + fn serialize_newtype_variant(self, name: &'static str, i: u32, variant: &'static str, v: &T) -> Result { + self.0.serialize_newtype_variant(name, i, variant, &Finite(v)) + } + fn serialize_seq(self, len: Option) -> Result { + self.0.serialize_seq(len).map(Wrap) + } + fn serialize_tuple(self, len: usize) -> Result { + self.0.serialize_tuple(len).map(Wrap) + } + fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_tuple_struct(name, len).map(Wrap) + } + fn serialize_tuple_variant(self, name: &'static str, i: u32, variant: &'static str, len: usize) -> Result { + self.0.serialize_tuple_variant(name, i, variant, len).map(Wrap) + } + fn serialize_map(self, len: Option) -> Result { + self.0.serialize_map(len).map(Wrap) + } + fn serialize_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_struct(name, len).map(Wrap) + } + fn serialize_struct_variant( + self, + name: &'static str, + i: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0.serialize_struct_variant(name, i, variant, len).map(Wrap) + } + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } +} + +macro_rules! compound { + ($tr:ident, $m:ident) => { + impl ser::$tr for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn $m(&mut self, v: &T) -> Result<(), S::Error> { + self.0.$m(&Finite(v)) + } + fn end(self) -> Result { + self.0.end() + } + } + }; +} +compound!(SerializeSeq, serialize_element); +compound!(SerializeTuple, serialize_element); +compound!(SerializeTupleStruct, serialize_field); +compound!(SerializeTupleVariant, serialize_field); + +impl ser::SerializeMap for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_key(&mut self, k: &T) -> Result<(), S::Error> { + self.0.serialize_key(&Finite(k)) + } + fn serialize_value(&mut self, v: &T) -> Result<(), S::Error> { + self.0.serialize_value(&Finite(v)) + } + fn end(self) -> Result { + self.0.end() + } +} + +macro_rules! fields { + ($tr:ident) => { + impl ser::$tr for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> { + self.0.serialize_field(key, &Finite(v)) + } + fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> { + self.0.skip_field(key) + } + fn end(self) -> Result { + self.0.end() + } + } + }; +} +fields!(SerializeStruct); +fields!(SerializeStructVariant); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_floats_are_written_as_zero_and_nothing_else_changes() { + #[derive(serde::Serialize)] + struct T { + a: f64, + b: Option, + c: Vec<(f32, f64)>, + d: Option, + e: std::collections::BTreeMap, + } + let t = T { a: f64::NAN, b: Some(f64::INFINITY), c: vec![(f32::NEG_INFINITY, 1.5)], d: None, e: [("k".to_string(), f64::NAN)].into() }; + let s = serde_json::to_string(&Finite(&t)).unwrap_or_default(); + assert_eq!(s, r#"{"a":0.0,"b":0.0,"c":[[0.0,1.5]],"d":null,"e":{"k":0.0}}"#); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d1bed6d6..cb917a2c 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -4,6 +4,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod finite; + use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -66,7 +68,7 @@ pub fn save(p: &Presentation) -> Result> { generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation: p.clone(), }; - let json = serde_json::to_vec(&m).map_err(|e| FormatError::Write(e.to_string()))?; + let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?; z.start_file("presentation.json", deflate).map_err(w)?; z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; for item in &p.media { @@ -132,7 +134,7 @@ pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> { fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec)> { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; - let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; + let m = parse_manifest(&json)?; if m.format != "deckcraft" { return Err(FormatError::NotOurs(m.format)); } @@ -196,6 +198,7 @@ pub fn repair(p: &mut Presentation) { if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) { p.notes_size = Presentation::default().notes_size; } + sane_numbers(p); } /// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows. @@ -236,6 +239,168 @@ fn add_outline_slides(p: &mut Presentation, slides: OutlineSlides) -> usize { made } +/// Most `null`s standing in for numbers that [`parse_manifest`] patches in one file, and the +/// candidates it tries for each. +const MAX_NULL_FIXES: usize = 256; +const MAX_TRIES_PER_FIX: usize = 32; +/// Bytes [`parse_manifest`] may re-parse in all while patching, so a hostile file full of `null`s +/// can't keep it busy: past this the file is reported damaged. +const MAX_REPARSE_BYTES: usize = 256 * 1024 * 1024; + +/// Where a parse failed, comparable: later is progress. +fn failure(src: &[u8]) -> std::result::Result { + serde_json::from_slice::(src).map_err(|e| (e.to_string(), (e.line(), e.column()))) +} + +/// Parse `presentation.json`. serde_json writes a NaN or infinite number as `null`, which then +/// fails to read back as a number ("invalid type: null, expected f64"), so DeckCraft could save +/// a file it couldn't open. Each such `null` is read as 0 instead ([`repair`] then brings the +/// value into range); anything else that doesn't parse is damage. +/// +/// serde reports the failing `null` exactly, except inside tagged enums (buffered), where it +/// reports the end of the enclosing object: there the `null`s before that point are tried +/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since +/// [`finite::Finite`] never contain such `null`s; this reads older ones.) +fn parse_manifest(json: &[u8]) -> Result { + let mut src = json.to_vec(); + let mut last = match failure(&src) { + Ok(m) => return Ok(m), + Err(f) => f, + }; + let mut budget = MAX_REPARSE_BYTES; + 'fixes: for _ in 0..MAX_NULL_FIXES { + let (msg, pos) = &last; + let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number"); + if !number { + break; + } + let end = offset(&src, pos.0, pos.1).unwrap_or(src.len()); + let mut progressed = false; + let candidates: Vec = value_nulls_before(&src, end + 4).take(MAX_TRIES_PER_FIX).collect(); + for at in candidates { + let (Some(head), Some(tail)) = (src.get(..at), src.get(at.saturating_add(4)..)) else { continue }; + let Some(left) = budget.checked_sub(src.len()) else { break 'fixes }; + budget = left; + let mut next = Vec::with_capacity(src.len()); + next.extend_from_slice(head); + next.push(b'0'); + next.extend_from_slice(tail); + match failure(&next) { + Ok(m) => return Ok(m), + // The patch shortened the text by 3 bytes before the error: compare positions + // allowing for that. + Err(f) if f.1 > (pos.0, pos.1.saturating_sub(3)) => { + src = next; + last = f; + progressed = true; + break; + } + Err(_) => {} + } + } + if !progressed { + break; + } + } + Err(FormatError::Damaged(last.0)) +} + +/// Byte offset of a 1-based `line`:`column`. +fn offset(src: &[u8], line: usize, column: usize) -> Option { + let mut start = 0; + for _ in 1..line { + start += src.get(start..)?.iter().position(|b| *b == b'\n')? + 1; + } + Some((start + column).min(src.len())) +} + +/// Starts of `null` tokens in value position (after `:`, `[` or `,`) before `end`, nearest first. +fn value_nulls_before(src: &[u8], end: usize) -> impl Iterator + '_ { + let end = end.min(src.len()); + (0..end.saturating_sub(3)).rev().filter(move |&i| { + src.get(i..i + 4) == Some(b"null") + && src[..i].iter().rev().find(|b| !b.is_ascii_whitespace()).is_some_and(|b| matches!(b, b':' | b'[' | b',')) + && src.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric()) + }) +} + +/// Largest coordinate or size kept, in points (about 350 m; PowerPoint's own limit is far smaller). +const MAX_COORD: f64 = 1.0e6; + +/// Numbers no view can draw or no file should keep: non-finite positions, sizes and angles become +/// 0, huge ones are clamped, angles are brought into 0–360, crops into a sane range, font sizes +/// into 1–4000 pt and line widths into 0–1584 pt (PowerPoint's limits). +fn sane_xfrm(x: &mut deckcraft_model::Xfrm) { + let fin = |v: f64| if v.is_finite() { v } else { 0.0 }; + x.x = fin(x.x).clamp(-MAX_COORD, MAX_COORD); + x.y = fin(x.y).clamp(-MAX_COORD, MAX_COORD); + x.w = fin(x.w).clamp(0.0, MAX_COORD); + x.h = fin(x.h).clamp(0.0, MAX_COORD); + x.rot = fin(x.rot).rem_euclid(360.0); + if x.rot >= 360.0 { + x.rot = 0.0; + } +} + +fn sane_run(p: &mut deckcraft_model::text::RunProps) { + p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0)); +} + +fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + sane_xfrm(x); + } + if let Some(l) = sh.line.as_mut() { + l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0)); + } + if let Some(t) = sh.text.as_mut() { + for para in &mut t.paragraphs { + sane_run(&mut para.end_props); + for r in &mut para.runs { + sane_run(&mut r.props); + } + } + } + match &mut sh.kind { + deckcraft_model::ShapeKind::Picture { fill } => { + for c in &mut fill.crop { + *c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 }; + } + } + deckcraft_model::ShapeKind::Group { children, child } => { + sane_xfrm(child); + sane_shapes(children, depth + 1); + } + _ => {} + } + } +} + +/// [`sane_shapes`] over every slide, layout and master; slides are only copied when they change. +pub fn sane_numbers(p: &mut Presentation) { + for slide in &mut p.slides { + let mut fixed = (**slide).clone(); + sane_shapes(&mut fixed.shapes, 0); + if fixed != **slide { + *slide = Arc::new(fixed); + } + } + for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) { + let mut fixed = (**m).clone(); + sane_shapes(&mut fixed.shapes, 0); + for l in &mut fixed.layouts { + sane_shapes(&mut l.shapes, 0); + } + if fixed != **m { + *m = Arc::new(fixed); + } + } +} + /// Outline text: one slide per unindented line; tab-indented lines become body bullets at their /// level (Insert ▸ Slides from Outline, View ▸ Outline copy). pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { @@ -449,6 +614,91 @@ mod tests { assert!(load(&b).is_err()); } + /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each + /// such number repaired (it failed as "invalid type: null, expected f64"). + #[test] + fn a_file_saved_with_non_finite_numbers_reopens() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = Presentation::default(); + let nan = f64::NAN; + let inf = f64::INFINITY; + let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; + pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); + let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; + let mut body = deckcraft_model::text::TextBody::default(); + let mut para = deckcraft_model::text::Paragraph::default(); + para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); + para.end_props.size = Some(1e9); + body.paragraphs.push(para); + text.text = Some(body); + let group = Shape { + xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), + kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(group); + let bytes = save(&p).unwrap(); + let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); + let g = back.slides[0].shapes.last().unwrap(); + let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; + assert_eq!((child.x, child.w), (0.0, 0.0)); + let x = children[0].xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; + assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); + assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); + assert_eq!(children[1].xfrm.unwrap().rot, 0.0); + let para = &children[1].text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.runs[0].props.size, None); + assert_eq!(para.end_props.size, Some(4000.0)); + // Saved again, it is clean. + assert_eq!(load(&save(&back).unwrap()).unwrap(), back); + } + + /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, + /// including a `null` inside a tagged enum, where serde reports only the enclosing object. + #[test] + fn older_files_with_null_numbers_open() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let nan = f64::NAN; + for case in 0..3 { + let mut p = Presentation::default(); + let sh = match case { + 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, + 1 => Shape { + kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, + ..Default::default() + }, + _ => Shape { + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }, + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; + let json = serde_json::to_vec(&m).unwrap(); + assert!(json.windows(4).any(|w| w == b"null")); + let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); + assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); + } + } + + /// Only `null`s where numbers belong are patched: other damage still fails. + #[test] + fn other_damage_is_still_reported() { + let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); + assert!(parse_manifest(&ok).is_ok()); + let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; + assert!(parse_manifest(bad).is_err()); + let truncated = &ok[..ok.len() - 3]; + assert!(parse_manifest(truncated).is_err()); + // A null number on a later line (pretty-printed by another tool) is found too. + let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; + let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); + } + #[test] fn degenerate_slide_sizes_are_repaired() { use deckcraft_model::Size; diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index dd41db37..3be72e03 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -8,6 +8,7 @@ pub mod morph; pub mod preset; +pub mod squircle; pub mod warp; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; diff --git a/crates/geom/src/squircle.rs b/crates/geom/src/squircle.rs new file mode 100644 index 00000000..8dedc972 --- /dev/null +++ b/crates/geom/src/squircle.rs @@ -0,0 +1,183 @@ +//! Rectangles with smoothed ("squircle") corners, as design tools offer next to the corner radius +//! (#286). A plain rounded corner joins the straight edge with a sudden change of curvature; a +//! smoothed corner starts curving earlier, along a cubic Bézier, and keeps a shorter circular arc +//! in the middle, so the outline flows into the edge. +//! +//! For a corner of radius `r` and smoothing `s` (0 = circular, 1 = no arc left), the corner takes +//! `p = (1 + s) · r` of each edge. The arc keeps `90° · (1 − s)` around the corner's diagonal and +//! each side of it is a cubic whose handles lie on the edge, so the curve is tangent to the edge +//! where it starts and to the arc where it meets it. When `p` would exceed half the shorter side, +//! the smoothing is reduced first, then the radius. The construction is our own derivation from +//! these constraints. + +/// The outline of a `w` x `h` rectangle with corners of radius `radius` smoothed by `smoothing` +/// (0..=1), as path data in the rectangle's own coordinates (`M`, `L`, `C`, `Z`; y down). +pub fn path_d(w: f64, h: f64, radius: f64, smoothing: f64) -> String { + let w = if w.is_finite() { w.max(0.0) } else { 0.0 }; + let h = if h.is_finite() { h.max(0.0) } else { 0.0 }; + let half = w.min(h) / 2.0; + let r = if radius.is_finite() { radius.clamp(0.0, half) } else { 0.0 }; + let mut s = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; + if r <= 0.0 { + return format!("M 0 0 L {w} 0 L {w} {h} L 0 {h} Z"); + } + s = s.min(half / r - 1.0).max(0.0); + let c = corner(r, s); + // The top-right corner in local terms: `u` runs back along the top edge from the corner, + // `v` down the right edge. Each corner maps (u, v) to the rectangle by symmetry. + let corners: [&dyn Fn(f64, f64) -> (f64, f64); 4] = [ + &|u, v| (w - u, v), // top right: from the top edge to the right edge + &|u, v| (w - v, h - u), // bottom right: from the right edge to the bottom edge + &|u, v| (u, h - v), // bottom left: from the bottom edge to the left edge + &|u, v| (v, u), // top left: from the left edge to the top edge + ]; + let mut d = String::new(); + let pt = |f: &dyn Fn(f64, f64) -> (f64, f64), (u, v): (f64, f64)| { + let (x, y) = f(u, v); + format!("{} {}", round(x), round(y)) + }; + for (i, f) in corners.iter().enumerate() { + let segs = c.segments(); + if i == 0 { + d.push_str(&format!("M {}", pt(*f, segs[0]))); + } else { + d.push_str(&format!(" L {}", pt(*f, segs[0]))); + } + for k in 0..3 { + let b = 1 + k * 3; + d.push_str(&format!(" C {} {} {}", pt(*f, segs[b]), pt(*f, segs[b + 1]), pt(*f, segs[b + 2]))); + } + } + d.push_str(" Z"); + d +} + +fn round(v: f64) -> f64 { + (v * 1000.0).round() / 1000.0 + 0.0 +} + +/// One corner in (u, v) terms: distance back along the incoming edge, distance along the outgoing. +struct Corner { + p: f64, + a: f64, + b: f64, + c: f64, + d: f64, + r: f64, + arc: f64, +} + +fn corner(r: f64, s: f64) -> Corner { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + let p = (1.0 + s) * r; + let arc = FRAC_PI_2 * (1.0 - s); + // The arc's chord, and where the edge curve meets the arc. + let arc_chord = (arc / 2.0).sin() * r * std::f64::consts::SQRT_2; + let alpha = (FRAC_PI_2 - arc) / 2.0; + let p3_p4 = r * (alpha / 2.0).tan(); + let beta = FRAC_PI_4 * s; + let c = p3_p4 * beta.cos(); + let d = c * beta.tan(); + let b = ((p - arc_chord - c - d) / 3.0).max(0.0); + let a = 2.0 * b; + Corner { p, a, b, c, d, r, arc } +} + +impl Corner { + /// Ten points: the start on the incoming edge, then three cubics (control, control, end): + /// edge → arc start, the arc, arc end → outgoing edge. + fn segments(&self) -> [(f64, f64); 10] { + let Corner { p, a, b, c, d, r, arc } = *self; + // Edge curve into the arc: handles on the edge, ending where the arc starts. + let e0 = (p, 0.0); + let e1 = (p - a, 0.0); + let e2 = (p - a - b, 0.0); + let e3 = (p - a - b - c, d); + // The arc around the centre (r, r) in (u, v), symmetric about the corner's diagonal. + // Angles from +u towards +v: the corner itself is at 5π/4 from the centre; the arc runs + // from the incoming edge's side (larger angle) to the outgoing edge's side. + let start = std::f64::consts::FRAC_PI_4 * 5.0 + arc / 2.0; + let end = start - arc; + let at = |t: f64| (r + r * t.cos(), r + r * t.sin()); + let k = 4.0 / 3.0 * (arc / 4.0).tan() * r; + let a0 = at(start); + let a3 = at(end); + // Tangent of the arc travelled towards smaller angles: (sin t, −cos t). + let a1 = (a0.0 + k * start.sin(), a0.1 - k * start.cos()); + let a2 = (a3.0 - k * end.sin(), a3.1 + k * end.cos()); + // Mirror of the edge curve onto the outgoing edge (swap u and v), reversed. + let o1 = (e2.1, e2.0); + let o2 = (e1.1, e1.0); + let o3 = (e0.1, e0.0); + let _ = (e3, a0); + [e0, e1, e2, a0, a1, a2, a3, o1, o2, o3] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn numbers(d: &str) -> Vec { + d.split_whitespace().filter_map(|t| t.parse().ok()).collect() + } + + #[test] + fn no_radius_is_a_rectangle() { + assert_eq!(path_d(100.0, 50.0, 0.0, 0.6), "M 0 0 L 100 0 L 100 50 L 0 50 Z"); + } + + #[test] + fn edge_curve_meets_the_arc() { + for s in [0.0, 0.3, 0.6, 1.0] { + let c = corner(10.0, s); + let seg = c.segments(); + let e3 = (c.p - c.a - c.b - c.c, c.d); + // The arc starts on the circle and where the edge curve ends. + assert!((seg[3].0 - e3.0).abs() < 1e-6 && (seg[3].1 - e3.1).abs() < 1e-6, "s={s}: {:?} vs {:?}", seg[3], e3); + // The arc is symmetric about the diagonal u = v. + assert!((seg[3].0 - seg[6].1).abs() < 1e-9 && (seg[3].1 - seg[6].0).abs() < 1e-9); + } + } + + #[test] + fn zero_smoothing_is_a_circular_corner() { + let c = corner(10.0, 0.0); + let seg = c.segments(); + assert!((c.p - 10.0).abs() < 1e-9); + assert!((seg[3].0 - 10.0).abs() < 1e-9 && seg[3].1.abs() < 1e-9); + assert!((seg[6].0).abs() < 1e-9 && (seg[6].1 - 10.0).abs() < 1e-9); + } + + #[test] + fn smoothing_uses_more_of_the_edge() { + let d0 = numbers(&path_d(200.0, 100.0, 20.0, 0.0)); + let d6 = numbers(&path_d(200.0, 100.0, 20.0, 0.6)); + // The first point is where the top-right corner starts on the top edge. + assert_eq!((d0[0], d0[1]), (180.0, 0.0)); + assert_eq!((d6[0], d6[1]), (168.0, 0.0)); + } + + #[test] + fn stays_inside_and_reduces_smoothing_when_the_rectangle_is_small() { + for (w, h, r, s) in [(100.0, 40.0, 20.0, 1.0), (40.0, 40.0, 50.0, 0.8), (10.0, 300.0, 4.0, 0.6), (300.0, 120.0, 30.0, 0.6)] { + let n = numbers(&path_d(w, h, r, s)); + assert!(n.chunks(2).all(|p| p[0] >= -1e-6 && p[0] <= w + 1e-6 && p[1] >= -1e-6 && p[1] <= h + 1e-6), "{w}x{h} r{r} s{s}"); + // Corners never cross the middle of an edge. + assert!(n[0] >= w / 2.0 - 1e-6); + } + assert!(numbers(&path_d(f64::NAN, 10.0, f64::INFINITY, f64::NAN)).iter().all(|v| v.is_finite())); + } + + #[test] + fn four_symmetric_corners() { + let n = numbers(&path_d(120.0, 80.0, 16.0, 0.6)); + // 4 corners x 10 points, 2 numbers each. + assert_eq!(n.len(), 80); + let pts: Vec<(f64, f64)> = n.chunks(2).map(|p| (p[0], p[1])).collect(); + for &(x, y) in &pts { + let mirrored = (120.0 - x, 80.0 - y); + assert!(pts.iter().any(|q| (q.0 - mirrored.0).abs() < 0.01 && (q.1 - mirrored.1).abs() < 0.01), "{x},{y}"); + } + } +} diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 17575461..d3035d51 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -286,6 +286,194 @@ pub const CATALOG: &[Preset] = &[ Preset { id: "textCascadeDown", label: "Cascade Down" }, ]; +/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect` +/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the +/// text rectangle's centre for an angle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpHandle { + pub index: usize, + pub pos: Point, + kind: HandleKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HandleKind { + /// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps. + Line { origin: Point, dir: Vec2, min: f64, max: f64 }, + /// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`. + Angle { center: Point, invert: bool }, +} + +impl WarpHandle { + /// The raw guide value (`adj` / `adjN`) for a pointer at `p`. + pub fn value_at(&self, p: Point) -> f64 { + match self.kind { + HandleKind::Line { origin, dir, min, max } => { + let l2 = dir.hypot2(); + if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) { + return min; + } + let v = (p - origin).dot(dir) / l2; + if !v.is_finite() { + return min; + } + let (lo, hi) = (min.min(max), max.max(min)); + v.clamp(lo, hi).round() + } + HandleKind::Angle { center, invert } => { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return 0.0; + } + let mut deg = d.y.atan2(d.x).to_degrees(); + if invert { + deg = 180.0 - deg; + } + (deg.rem_euclid(360.0) * 60_000.0).round() + } + } + } +} + +fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 { + adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) +} + +fn unit(rect: Rect, u: f64, v: f64) -> Point { + Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height()) +} + +/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`. +fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, x, sign * frac), + kind: HandleKind::Line { + origin: unit(rect, x, 0.0), + dir: Vec2::new(0.0, sign * rect.height() / 100_000.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// A fraction handle that slides horizontally: unit `(frac, y)`. +fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, frac, y), + kind: HandleKind::Line { + origin: unit(rect, 0.0, y), + dir: Vec2::new(rect.width() / 100_000.0, 0.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle. +fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle { + let geom = if invert { 180.0 - deg } else { deg }; + let rad = geom.to_radians(); + let c = rect.center(); + WarpHandle { + index, + pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()), + kind: HandleKind::Angle { center: c, invert }, + } +} + +/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse. +fn radius_handle(rect: Rect, r: f64) -> WarpHandle { + let r = r.clamp(0.0, 0.99); + WarpHandle { + index: 1, + pos: unit(rect, 0.5, 0.5 + 0.5 * r), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 }, + } +} + +/// Wave phase: slides sideways around the middle of the lower half. +fn shift_handle(rect: Rect, s: f64) -> WarpHandle { + let s = s.clamp(-0.1, 0.1); + WarpHandle { + index: 1, + pos: unit(rect, 0.5 + s, 0.78), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 }, + } +} + +/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn +/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults. +pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + if rect.width() < 1e-3 || rect.height() < 1e-3 { + return Vec::new(); + } + let defaults = adjust_defaults(name); + if defaults.is_empty() { + return Vec::new(); + } + let raw = |i| stored_raw(adj, defaults, i); + let frac = |i| raw(i) / 100_000.0; + let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0); + let y = |out: &mut Vec, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign)); + let xh = |out: &mut Vec, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi)); + let mut out = Vec::with_capacity(defaults.len()); + match name { + "textPlain" => xh(&mut out, 0.28, 0.3, 0.7), + "textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0), + "textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0), + "textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0), + "textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0), + "textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0), + "textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999), + "textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999), + "textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0), + "textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0), + "textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0), + "textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0), + "textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0), + "textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0), + "textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0), + "textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0), + "textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0), + "textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0), + "textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0), + "textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0), + "textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0), + "textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)), + "textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)), + "textArchUpPour" | "textCirclePour" | "textButtonPour" => { + out.push(angle_handle(rect, 0, deg(0), false)); + out.push(radius_handle(rect, frac(1))); + } + "textArchDownPour" => { + out.push(angle_handle(rect, 0, deg(0), true)); + out.push(radius_handle(rect, frac(1))); + } + "textWave1" | "textWave2" => { + y(&mut out, 0.32, 0.0, 0.2, 1.0); + out.push(shift_handle(rect, frac(1))); + } + "textDoubleWave1" | "textWave4" => { + y(&mut out, 0.32, 0.0, 0.125, 1.0); + out.push(shift_handle(rect, frac(1))); + } + _ => return Vec::new(), + } + out +} + /// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). #[derive(Clone, Copy, Debug)] enum Seg { diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index 7a2c3a14..c04ddd10 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -277,3 +277,43 @@ fn hostile_adjusts_and_boxes_stay_finite() { } assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); } + +#[test] +fn every_preset_has_one_handle_per_adjust() { + let r = rect(); + for name in CATALOG.iter().map(|p| p.id) { + let hs = handles(name, &[], r); + assert_eq!(hs.len(), adjust_count(name), "{name}"); + let defaults = adjust_defaults(name); + for h in &hs { + assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}"); + let back = h.value_at(h.pos); + let want = defaults[h.index]; + assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index); + } + } + assert!(handles("textNoShape", &[], r).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty()); + // A hostile adjust still places a finite handle on the clamped default. + let hs = handles("textInflate", &[f64::NAN], r); + assert_eq!(hs.len(), 1); + assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite()); +} + +#[test] +fn arch_and_inflate_handles_follow_the_pointer() { + let r = rect(); + let arch = &handles("textArchUp", &[], r)[0]; + let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0; + assert!((deg - 270.0).abs() < 0.5, "{deg}"); + let down = &handles("textArchDown", &[], r)[0]; + // The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90 + // (guide angle 180 − adj). + let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0; + assert!((deg - 90.0).abs() < 0.5, "{deg}"); + let inf = &handles("textInflate", &[], r)[0]; + let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0)); + assert!(more > adjust_defaults("textInflate")[0], "{more}"); + assert!((more - 20_000.0).abs() < 1.0, "{more}"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 36a27418..bd1faf09 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -101,7 +101,8 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?; let mods = e.get("mods").map(mods_from).unwrap_or(base); - last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0 }).map_err(|e| e.to_string())?; + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0, pressure }).map_err(|e| e.to_string())?; } self.session.ui_requests.clear(); Ok(json!({"result": last, "selection": self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())})) diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 576e0b4a..710067cd 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -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"; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index db5ca5bf..f34ee867 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -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); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b5eab7f9..b964ea10 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -115,23 +115,32 @@ pub fn tool_definitions() -> Vec { 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"], ), diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index 356c3704..ed67fb3e 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -166,7 +166,12 @@ fn every_writing_command_is_refused_outside_the_write_root() { for f in ["x.deckcraft", "x.pptx", "x.pdf", "all1.png", "all2.png", "r.png"] { assert!(base.join("out").join(f).exists(), "{f}"); } - assert!(!base.join("out/x.deckcraft.saving").exists(), "the temporary file is renamed away"); + let temporaries: Vec<_> = std::fs::read_dir(base.join("out")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".saving")) + .collect(); + assert!(temporaries.is_empty(), "the temporary file is renamed away"); let _ = std::fs::remove_dir_all(base); } diff --git a/crates/media/src/player.rs b/crates/media/src/player.rs index 1e069544..dbb9d2c0 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -13,6 +13,7 @@ //! different source replaces its audio instead of keeping the previous clip. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use crate::{Bytes, ClipParams, Pcm}; @@ -22,6 +23,12 @@ use crate::{Bytes, ClipParams, Pcm}; pub trait AudioOut { fn start(&mut self, fill: Box) -> Result<(), String>; fn stop(&mut self); + /// Why the started stream has died, if it has (the device went away), reported once. Polled + /// by [`Player::tick`] on the host thread; must not block. Outputs that cannot tell return + /// `None`: the player also notices a stream whose callbacks stopped. + fn failure(&mut self) -> Option { + None + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -197,6 +204,17 @@ impl Mixer { /// Default mix rate when no device says otherwise. const DEFAULT_RATE: u32 = 48_000; +/// Seconds without a single device callback, while a voice plays, after which the stream counts +/// as stalled and the wall clock takes over. +const STALL_SECS: f64 = 1.0; + +/// Seconds a just-started stream may take to ask for its first samples (Bluetooth and other slow +/// devices) before it counts as stalled. +const FIRST_CALLBACK_SECS: f64 = 5.0; + +/// Output failures after which the player stops retrying the device and plays silently. +const MAX_OUTPUT_FAILURES: u32 = 3; + pub struct Player { out: Option>, streaming: bool, @@ -204,12 +222,31 @@ pub struct Player { last_tick: Option, idle_since: Option, cache: HashMap, + /// Device callbacks so far (the audio thread counts, [`Player::tick`] watches). + pulls: Arc, + /// The pull count last seen by `tick` and when it last changed. + pulls_seen: Option<(u64, f64)>, + /// Times the output failed or stalled after starting. + failures: u32, + /// `pulls` when the current stream started: callbacks beyond it mean the device is running. + pulls_at_start: u64, } impl Player { /// A player over `out` (`None`: silent, clocked by [`Player::tick`]). pub fn new(out: Option>) -> Player { - Player { out, streaming: false, mixer: Arc::new(Mutex::new(Mixer::default())), last_tick: None, idle_since: None, cache: HashMap::new() } + Player { + out, + streaming: false, + mixer: Arc::new(Mutex::new(Mixer::default())), + last_tick: None, + idle_since: None, + cache: HashMap::new(), + pulls: Arc::new(AtomicU64::new(0)), + pulls_seen: None, + failures: 0, + pulls_at_start: 0, + } } pub fn has_output(&self) -> bool { @@ -290,15 +327,21 @@ impl Player { } let Some(out) = self.out.as_mut() else { return }; let m = self.mixer.clone(); + let pulls = self.pulls.clone(); let fill = Box::new(move |buf: &mut [f32], channels: usize, rate: u32| match m.lock() { Ok(mut g) => { + pulls.fetch_add(1, Ordering::Relaxed); let frames = buf.len() / channels.max(1); g.mix(Some(buf), frames, channels, rate) } Err(_) => buf.fill(0.0), }); match out.start(fill) { - Ok(()) => self.streaming = true, + Ok(()) => { + self.streaming = true; + self.pulls_seen = None; + self.pulls_at_start = self.pulls.load(Ordering::Relaxed); + } Err(e) => { log::warn!("audio output unavailable ({e}); media plays silently"); self.out = None; @@ -361,6 +404,7 @@ impl Player { pub fn tick(&mut self, now: f64) { let dt = self.last_tick.map(|l| (now - l).clamp(0.0, 0.25)).unwrap_or(0.0); self.last_tick = Some(now); + self.check_output(now); if !self.streaming && dt > 0.0 { let frames = (dt * DEFAULT_RATE as f64).round() as usize; self.with(|m| m.mix(None, frames, 2, DEFAULT_RATE)); @@ -378,6 +422,56 @@ impl Player { } } + /// Drop a stream that died or stalled, so the voices fall back to the wall clock instead of + /// freezing. The next `play` tries the device again, up to [`MAX_OUTPUT_FAILURES`] times. + fn check_output(&mut self, now: f64) { + if !self.streaming { + return; + } + // Only a playing voice needs callbacks; an idle stream may legitimately sleep. + let playing = self.any_playing(); + let Some(out) = self.out.as_mut() else { return }; + let pulls = self.pulls.load(Ordering::Relaxed); + let running = pulls > self.pulls_at_start; + // A stream that delivers again has recovered: later failures start counting afresh. + if running { + self.failures = 0; + } + let limit = if running { STALL_SECS } else { FIRST_CALLBACK_SECS }; + let reason = if let Some(e) = out.failure() { + Some(e) + } else { + match self.pulls_seen { + Some((seen, since)) if seen == pulls => { + if !playing { + self.pulls_seen = Some((pulls, now)); + None + } else if now - since > limit { + Some("the audio device stopped asking for samples".to_string()) + } else { + None + } + } + _ => { + self.pulls_seen = Some((pulls, now)); + None + } + } + }; + let Some(reason) = reason else { return }; + out.stop(); + self.streaming = false; + self.pulls_seen = None; + self.idle_since = None; + self.failures = self.failures.saturating_add(1); + if self.failures >= MAX_OUTPUT_FAILURES { + log::warn!("audio output failed ({reason}); giving up on the device, media plays silently"); + self.out = None; + } else { + log::warn!("audio output failed ({reason}); media plays silently until it restarts"); + } + } + /// Block until voice `key`'s audio has decoded (tests, offline rendering). pub fn wait_loaded(&self, key: u64, timeout: std::time::Duration) -> bool { let slot = self.with(|m| m.voices.iter().find(|v| v.key == key).and_then(|v| v.audio.clone())); @@ -595,4 +689,137 @@ mod tests { let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); } + + type Fill = Box; + + /// An output that starts, then fails or goes quiet on command. + #[derive(Clone, Default)] + struct Flaky { + fill: Arc>>, + fail: Arc>>, + starts: Arc>, + } + impl AudioOut for Flaky { + fn start(&mut self, fill: Fill) -> Result<(), String> { + *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); + *self.starts.lock().map_err(|e| e.to_string())? += 1; + Ok(()) + } + fn stop(&mut self) { + if let Ok(mut f) = self.fill.lock() { + *f = None; + } + } + fn failure(&mut self) -> Option { + self.fail.lock().ok().and_then(|mut f| f.take()) + } + } + impl Flaky { + fn pull(&self, frames: usize) { + let mut buf = vec![0.0; frames * 2]; + if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { + f(&mut buf, 2, 48_000); + } + } + fn starts(&self) -> u32 { + self.starts.lock().map(|s| *s).unwrap_or(0) + } + } + + fn pos(p: &Player, key: u64) -> f64 { + p.status(key).map(|s| s.position).unwrap_or(-1.0) + } + + #[test] + fn a_failed_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.tick(0.0); + out.pull(4800); + assert!((pos(&p, 1) - 0.1).abs() < 1e-6); + if let Ok(mut f) = out.fail.lock() { + *f = Some("unplugged".into()); + } + p.tick(0.2); + p.tick(0.4); + // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then + // the 0.4 s of wall time since the last tick before the failure. + assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); + // Playing again restarts the device. + p.play(2, 2, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 2); + assert!(p.has_output()); + } + + #[test] + fn a_stalled_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + // No callback ever arrives: after the first-callback grace the wall clock takes over. + for i in 0..=21 { + p.tick(i as f64 * 0.25); + } + let before = pos(&p, 1); + assert!(before > 0.0, "{before}"); + p.tick(5.5); + assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); + } + + #[test] + fn a_slow_device_that_answers_within_the_grace_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Two seconds in, the device starts asking for samples: the same stream keeps playing. + out.pull(256); + p.tick(2.25); + p.tick(2.5); + assert_eq!(out.starts(), 1, "the stream was not restarted"); + } + + #[test] + fn an_idle_stream_without_callbacks_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.pause(1); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Resuming keeps the running stream: no restart. + p.play(1, 1, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 1); + } + + #[test] + fn repeated_failures_give_up_on_the_device() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + let mut now = 0.0; + for _ in 0..MAX_OUTPUT_FAILURES { + p.play(1, 1, &b, spec(100.0, false), None); + if let Ok(mut f) = out.fail.lock() { + *f = Some("broken".into()); + } + p.tick(now); + now += 0.1; + } + assert!(!p.has_output()); + assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); + // Still plays, silently on wall time. + let before = pos(&p, 1); + p.tick(now + 0.2); + assert!(pos(&p, 1) > before); + } } diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 2d2491fe..f30b3cfd 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -242,6 +242,76 @@ pub struct Series { /// Trendlines (`c:trendline`), in file order. #[serde(skip_serializing_if = "Vec::is_empty")] pub trendlines: Vec, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +/// Count, mean and sample standard deviation of a series' finite values (for error bars). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SeriesStats { + pub n: f64, + pub mean: f64, + pub sd: f64, +} + +impl SeriesStats { + pub fn of(values: &[Option]) -> Self { + let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); + let n = vals().count() as f64; + let mean = vals().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + SeriesStats { n, mean, sd } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + self.range_in(&SeriesStats::of(values), i, v) + } + + /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so + /// drawing every point of a long series stays linear. + pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { + let SeriesStats { n, mean, sd } = *stats; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -732,6 +802,23 @@ mod tests { assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); } + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 5507b23a..5300de8b 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -515,6 +515,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen table_styles: vec![], tags: vec![], kept_parts: vec![], + guides: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index 2d6d69da..1c38bf74 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -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 = body.text().chars().collect(); + let new: Vec = 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"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 2a02ac92..c62a5adb 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -252,7 +252,8 @@ pub struct MediaClip { pub height: u32, } -/// One ink stroke (Draw tab): points in slide coordinates with pressure. +/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`, +/// where `1` is [`InkStroke::width`]). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct InkStroke { @@ -262,6 +263,24 @@ pub struct InkStroke { pub highlighter: bool, } +/// True when every sample is the placeholder `0.5` written before a device reported pressure. +/// Those strokes were drawn at [`InkStroke::width`] and still are. +pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool { + !points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3) +} + +/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke +/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too. +/// A light touch stays visible (at least 8% of the pen). +pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 { + let pen = if pen.is_finite() { pen.abs() } else { 0.0 }; + if legacy { + return pen; + } + let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 }; + pen * f64::from(p) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum ShapeKind { @@ -980,11 +999,23 @@ pub struct Presentation { /// sensitivity label and custom document properties. #[serde(skip_serializing_if = "Vec::is_empty")] pub kept_parts: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + impl Default for Presentation { fn default() -> Self { defaults::new_presentation(None) diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index bac62789..6b2a494d 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -238,3 +238,16 @@ fn sibling_lists_and_parents_reach_into_groups() { assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); } + +#[test] +fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { + assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)])); + assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)])); + assert!(!ink_pressure_is_legacy(&[])); + assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9); + let floor = ink_sample_width(8.0, 0.0, false); + assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); + assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); +} diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 44a0b7ac..c1553c14 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -504,6 +504,21 @@ impl Paragraph { pub fn new(text: &str) -> Self { Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() } } + /// Leading tabs are the outline level (at most 8), the same rule as a typed indent. + /// Returns whether any tab was consumed. + pub fn promote_leading_tabs(&mut self) -> bool { + let Some(r) = self.runs.first_mut() else { return false }; + if r.kind != RunKind::Text { + return false; + } + let tabs = r.text.chars().take_while(|c| *c == '\t').count(); + if tabs == 0 { + return false; + } + r.text = r.text.trim_start_matches('\t').to_string(); + self.level = tabs.min(8) as u8; + true + } pub fn text(&self) -> String { self.runs.iter().map(|r| r.content()).collect() } diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 49114976..bc2b7062 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -1,6 +1,6 @@ //! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`. -use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; use deckcraft_model::style::Fill; use crate::xml::El; @@ -265,6 +265,21 @@ fn chart_within(root: &El, mut budget: usize) -> Option { if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { ser.values = numbers(v, &mut budget); } + // Error bars on the values: a scatter chart's y bars, any other chart's only ones. + if let Some(e) = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) + { + let d = ErrorBars::default(); + ser.error_bars = Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), + minus: e.child("minus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }); + } if let Some(b) = s.child("bubbleSize") { ser.sizes = numbers(b, &mut budget); } @@ -460,6 +475,14 @@ mod tests { assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0)); } + #[test] + fn error_bars_are_read() { + let xml = br#"5"#; + let c = read_chart_xml(xml).unwrap(); + let e = c.series[0].error_bars.clone().unwrap(); + assert_eq!((e.bar_type.as_str(), e.val_type.as_str(), e.value, e.end_cap), ("plus", "percentage", 20.0, false)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 25b42515..ac83dfc0 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -578,6 +578,20 @@ pub fn import(bytes: &[u8]) -> Result { p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); } } + // Drawing guides (eighths of a point). + if let Some(r) = pres_part.rels.first_of("viewProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) + { + p.guides = lst + .children_named("guide") + .filter_map(|g| { + let pos = g.attr("pos").and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 8.0; + pos.is_finite().then_some(deckcraft_model::DrawGuide { vertical: g.attr("orient") != Some("horz"), pos }) + }) + .take(1000) + .collect(); + } // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 3240677d..347f3a7b 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -111,6 +111,20 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option]) { w.close("c:numRef"); } +/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them. +fn num_lit(w: &mut W, vals: &[Option]) { + w.open0("c:numLit"); + w.val("c:ptCount", vals.len()); + for (i, v) in vals.iter().enumerate() { + if let Some(v) = v.filter(|v| v.is_finite()) { + w.open("c:pt", A::new().a("idx", i)); + w.elt("c:v", &fmt_num(v)); + w.close("c:pt"); + } + } + w.close("c:numLit"); +} + #[derive(Clone, Copy, PartialEq)] enum Group { Bar, @@ -212,6 +226,28 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } let n = c.categories.len().max(s.values.len()); let last = n + 1; + if let Some(e) = &s.error_bars { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); + } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; w.open0("c:xVal"); diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 45689d0e..ed09ca69 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -687,7 +687,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { pres.rels.add("viewProps", "viewProps.xml", false); pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); - x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); + x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml(p)); let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), handout_rid.as_deref(), &slide_rids); @@ -1155,13 +1155,34 @@ fn pres_props_xml(p: &Presentation) -> Vec { w.finish() } -fn view_props_xml() -> Vec { +fn view_props_xml(p: &Presentation) -> Vec { let mut w = W::new(); root_open(&mut w, "p:viewPr", A::new()); w.open0("p:normalViewPr"); w.empty("p:restoredLeft", A::new().a("sz", 15620)); w.empty("p:restoredTop", A::new().a("sz", 94660)); w.close("p:normalViewPr"); + if !p.guides.is_empty() { + // Drawing guides; `pos` is in eighths of a point (576 per inch). + w.open0("p:slideViewPr"); + w.open0("p:cSldViewPr"); + w.open0("p:cViewPr"); + w.open0("p:scale"); + w.empty("a:sx", A::new().a("n", 100).a("d", 100)); + w.empty("a:sy", A::new().a("n", 100).a("d", 100)); + w.close("p:scale"); + w.empty("p:origin", A::new().a("x", 0).a("y", 0)); + w.close("p:cViewPr"); + w.open0("p:guideLst"); + for g in p.guides.iter().take(1000) { + let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64; + let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) }; + w.empty("p:guide", a); + } + w.close("p:guideLst"); + w.close("p:cSldViewPr"); + w.close("p:slideViewPr"); + } w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200)); w.close("p:viewPr"); w.finish() diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index ecfb4973..fab67be4 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -590,6 +590,15 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { return None; } let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0)); + // PPTX keeps one line width. A pressured stroke exports at its mean sample width; + // a legacy flat `0.5` stroke stays at the pen width. + let legacy = deckcraft_model::ink_pressure_is_legacy(&st.points); + let width = if legacy { + st.width + } else { + let n = st.points.len().max(1) as f64; + st.points.iter().map(|p| deckcraft_model::ink_sample_width(st.width, p.2, false)).sum::() / n + }; let mut d = String::new(); for (k, (px, py)) in pts.iter().enumerate() { d.push_str(if k == 0 { "M " } else { "L " }); @@ -608,7 +617,7 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { line: Some(deckcraft_model::Line { cap: Some(deckcraft_model::style::LineCap::Round), join: Some(deckcraft_model::style::LineJoin::Round), - ..deckcraft_model::Line::solid(c, st.width.max(0.25)) + ..deckcraft_model::Line::solid(c, width.max(0.25)) }), ..Default::default() }) diff --git a/crates/pptx/tests/notes.rs b/crates/pptx/tests/notes.rs new file mode 100644 index 00000000..b5863d51 --- /dev/null +++ b/crates/pptx/tests/notes.rs @@ -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#" +Metodický prehľadŽiaci pracujú vo dvojiciach.Cieľ: vysvetliť cyklusReflexiaBez odrážkyPomôcky: počítač"#; + +/// A deck with one notes slide, its notes replaced by [`NOTES_XML`]. +fn deck() -> Vec { + 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#""), "{xml}"); +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index bf177dc0..8bb1816c 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -491,9 +491,40 @@ fn pictures_media_and_ink_round_trip() { assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250)); assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp"))); assert!(m.poster.is_some()); - // Ink comes back as freeform lines in a group. + // Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width. let ink = by_name(&q.slides[2], "Ink"); - assert!(matches!(&ink.kind, ShapeKind::Group { children, .. } if children.len() == 1 && matches!(children[0].geom, Geom::Custom { .. }))); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) }; + assert_eq!(children.len(), 1); + assert!(matches!(children[0].geom, Geom::Custom { .. })); + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + assert!((w - 3.0).abs() < 0.05, "a flat 0.5 stroke keeps the pen width, got {w}"); +} + +#[test] +fn pressured_ink_exports_at_its_mean_width() { + let mut p = Presentation::default(); + let id = ShapeId(p.alloc_id()); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + slide.shapes.push(Shape { + id, + name: "Ink".into(), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![InkStroke { + points: vec![(10.0, 10.0, 1.0), (40.0, 10.0, 0.5), (80.0, 10.0, 0.0)], + color: Rgba::rgb(0, 0, 0), + width: 10.0, + highlighter: false, + }], + }, + ..Default::default() + }); + let q = round(&p); + let ink = by_name(&q.slides[0], "Ink"); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("{:?}", ink.kind) }; + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + // Full, half, and the 8% floor: (10 + 5 + 0.8) / 3. + assert!((w - (10.0 + 5.0 + 0.8) / 3.0).abs() < 0.05, "{w}"); } #[test] diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 1f8730d6..ac185780 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -573,6 +573,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, Some((b, _)) => to_v(b.powf(min)), None => to_v(clamp_soft(0.0, min, max)), }; + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); @@ -586,6 +588,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let bar = band / (n - (n - 1.0) * ov + gap); let step = bar * (1.0 - ov); let group = bar + step * (n - 1.0); + // Error bars need each series' mean and spread: worked out once, not per point. + let stats: Vec = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect(); for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; @@ -645,6 +649,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, ctx.set_stroke(kurbo::Stroke::new(0.75)); ctx.stroke_path(&j); } + if let Some(e) = &s.error_bars { + let (end, mid) = if horiz { (if v >= 0.0 { b } else { a }, r.center().y) } else { (if v >= 0.0 { b } else { a }, r.center().x) }; + if let Some((lo, hi)) = stats.get(bi).and_then(|st| e.range_in(st, i, end)) { + let (p0, p1) = if horiz { (to_v(lo), to_v(hi)) } else { (to_axis(sec, lo), to_axis(sec, hi)) }; + err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap)); + } + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { @@ -708,6 +719,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } _ => plot.x0 + band * (slot(i) + 0.5), }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| e.range(&s.values, i, v)) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } pts.push(Point::new(x, to_axis(sec, v))); } if pts.is_empty() { @@ -740,6 +754,25 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, // A category position (0-based, fractional) as a slot, the way `slot` places the bars. let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } // Axis line. let mut ax = BezPath::new(); if horiz { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index adf23ff8..926bb762 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -930,24 +930,46 @@ impl Renderer { } ShapeKind::Ink { strokes } => { for sk in strokes { - let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; - for (px, py, _) in &sk.points { + let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); + let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, pr) in &sk.points { + let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); } - let mut bp = BezPath::new(); - for (i, (px, py, _)) in sk.points.iter().enumerate() { - if i == 0 { - bp.move_to((*px, *py)); - } else { - bp.line_to((*px, *py)); - } + let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); + if pts.len() < 2 { + continue; } let (base, rel) = self.line_space(parent, depth); - bp.apply_affine(rel); ctx.set_transform(base); ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); - ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); + // Uniform-width and highlighter strokes are one path: segments drawn one by one + // overlap at the joins, which darkens see-through highlighter ink in beads. + if legacy || sk.highlighter { + let mut bp = BezPath::new(); + for (i, (px, py, _)) in pts.iter().enumerate() { + if i == 0 { + bp.move_to((*px, *py)); + } else { + bp.line_to((*px, *py)); + } + } + bp.apply_affine(rel); + ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + continue; + } + for pair in pts.windows(2) { + let [p0, p1] = pair else { continue }; + let mut bp = BezPath::new(); + bp.move_to((p0.0, p0.1)); + bp.line_to((p1.0, p1.1)); + bp.apply_affine(rel); + let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy); + let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy); + ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } } } _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), @@ -2247,6 +2269,49 @@ fn draw_glyph_with( } } +/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). +pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { + if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { + return None; + } + let rctx = Ctx::for_slide(pres, slide)?; + let mut items = Vec::new(); + let mut bounds: Option = None; + for id in ids.iter().take(256) { + let Some(s) = slide.shape(*id) else { continue }; + let b = resolve::xfrm(&rctx, s).bounds(); + if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { + continue; + } + bounds = Some(match bounds { + Some(u) => u.union(b), + None => b, + }); + items.push(s); + } + let b = bounds?.inflate(8.0, 8.0); + if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { + return None; + } + let fit = (2048.0 / b.width().max(b.height())).min(scale); + if !(fit.is_finite() && fit > 0.0) { + return None; + } + let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; + let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale: fit, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + for s in items { + r.shape(&mut ctx, &f, &rctx, s, view, 0); + } + Some(r.finish(ctx, w, h)) + }) +} + /// Render one shape alone onto a transparent canvas (drag previews, copy as picture). pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { let rctx = Ctx::for_slide(pres, slide)?; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index bdd9b17e..1759855e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -35,6 +35,65 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +#[test] +fn render_shapes_draws_the_selection_on_a_transparent_canvas() { + let sh = Shape { + id: ShapeId(880), + xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)), + fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))), + line: Some(deckcraft_model::Line::none()), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture"); + let mid = img.pixel(img.width / 2, img.height / 2); + assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}"); + assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0)); +} + +#[test] +fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() { + let opts = RenderOpts { scale: 1.0, ..Default::default() }; + let tapered = render_slide(&ink_line(1.0, 0.12), 0, &opts); + let flat = render_slide(&ink_line(0.5, 0.5), 0, &opts); + let full = render_slide(&ink_line(1.0, 1.0), 0, &opts); + let heavy = dark_in(&tapered, 50, 140, 70, 130); + let light = dark_in(&tapered, 280, 360, 70, 130); + assert!(heavy > light * 3, "a firm point covers more of the slide than a light one ({heavy} vs {light})"); + let (a, b) = (dark_in(&flat, 40, 370, 70, 130), dark_in(&full, 40, 370, 70, 130)); + assert!(a > 100 && (a as i32 - b as i32).unsigned_abs() * 10 < a as u32, "the old constant 0.5 still draws at the pen width ({a} vs {b})"); +} + +fn ink_line(left: f32, right: f32) -> Presentation { + let points = (0..=40) + .map(|i| { + let pressure = if i < 20 { left } else { right }; + (40.0 + f64::from(i) * 8.0, 100.0, pressure) + }) + .collect(); + let sh = Shape { + id: ShapeId(7), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![deckcraft_model::InkStroke { points, color: deckcraft_color::Rgba::rgb(0, 0, 0), width: 24.0, highlighter: false }], + }, + ..Default::default() + }; + deck_with(vec![sh]) +} + +fn dark_in(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize { + let mut n = 0; + for y in y0..y1 { + for x in x0..x1 { + if img.pixel(x, y)[0] < 200 { + n += 1; + } + } + } + n +} + #[test] fn text_draws_ink() { let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b9e28fb1..840b7834 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1170,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option { 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") { diff --git a/crates/ui-egui/src/background_tests.rs b/crates/ui-egui/src/background_tests.rs new file mode 100644 index 00000000..0bb3d229 --- /dev/null +++ b/crates/ui-egui/src/background_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index dccb10ff..9eb50898 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -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); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9a6a7163..bc4e080e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,6 +35,33 @@ fn mods(ui: &Ui) -> Mods { ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl }) } +/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; +/// other platforms leave the delta at zero. +/// +/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom +/// and two-finger scrolling are left as they are. +pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { + let delta = ctx.input(|i| i.rotation_delta()); + let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; + let turning = delta.abs() > 1e-4; + let blocked = app.show.is_some() + || app.backstage_open() + || app.dialog.as_ref().is_some_and(|d| d.modal()) + || app.palette.is_some() + || app.session.tool.dragging(); + let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); + let rotatable = app.session.active().is_some_and(|d| { + d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) + }); + if !blocked && turning && rotatable && (app.twisting || over) { + let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); + app.twisting = true; + } else if app.twisting { + let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); + app.twisting = false; + } +} + /// The slide's rect on screen for the current zoom. pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let Some(d) = app.session.active() else { return (avail, 1.0) }; @@ -44,7 +71,8 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let (sw, sh) = (side(page.width), side(page.height)); let margin = 28.0; let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05); - let scale = app.ui.zoom.unwrap_or(fit); + // A zoom from outside the editor's own controls (settings, the control protocol) is kept in range. + let scale = app.ui.zoom.filter(|z| z.is_finite() && *z > 0.0).map_or(fit, |z| z.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); let size = vec2(sw * scale, sh * scale); let mut r = Rect::from_center_size(avail.center(), size); if size.x > avail.width() { @@ -56,6 +84,17 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } +/// A trackpad flick or mouse-wheel notch that should turn the page. +/// +/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative +/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — +/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. +pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { + const MIN_FLICK: f32 = 18.0; + let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; + (along.abs() > MIN_FLICK).then_some(along < 0.0) +} + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -95,24 +134,33 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if resp.hovered() && !zooming { if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - // Wheel over a fitted slide pages through slides (like PowerPoint). - let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" }; + } else if !app.anim_preview_active() + && app.session.tool.marquee.is_none() + && !app.session.tool.dragging() + && let Some(next) = page_flick(wheel) + { + // A fitted slide: the mouse wheel and a trackpad swipe turn the page. Pinch zoom is + // the branch above and is left alone. let gate = ui.id().with("wheel_gate"); - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0)); + // No previous flick yet: the first one must turn the page, not wait out the gap. + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); let now = ui.input(|i| i.time); if now - last > 0.35 { ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(id, json!({})); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); } } } 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; @@ -120,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() { @@ -129,6 +176,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { }; crate::media::gif_repaint(ui.ctx(), &gifs); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); + let anim = app.anim_preview_draw(); let st = app.session.active(); // One texture pixel in screen points, and the canvas in pixels from the slide's corner. let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32); @@ -137,10 +185,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { Some((Target::Slides, d)) => { let idx = d.selection.slide; let doc = d.doc.clone(); + let frame = anim.as_ref().map(|(tl, t)| (tl, *t)); // A placeholder's prompt makes way for the caret once it is being typed into. let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape); let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty())); - app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt) + app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt, frame) } Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), @@ -198,45 +247,33 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { painter.extend(dash); } } - if app.ui.gridlines { - let step = 72.0 / 2.0 * scale; - let mut x = slide_rect.min.x + step; - while x < slide_rect.max.x { - painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - x += step; - } - let mut y = slide_rect.min.y + step; - while y < slide_rect.max.y { - painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - y += step; - } + if app.ui.gridlines + && let Some(step) = grid_line_step(app.session.prefs.grid_spacing, scale) + { + draw_grid(&painter, slide_rect, step); } + app.session.tool.guides_visible = app.ui.guides; if app.ui.guides { - let c = slide_rect.center(); - painter.line_segment( - [pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); - painter.line_segment( - [pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); + draw_guides(app, &painter, xf, slide_rect, &t); } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); + ruler_markers(app, ui, &painter, avail, xf, &t); } pointer(app, ui, &resp, xf); - overlays(app, ui, &painter, xf, &t); - ime_output(app, ui, xf); - media_bar(app, ui, xf); - gif_buttons(app, ui, xf, &gifs); - if resp.secondary_clicked() - && let Some(p) = resp.interact_pointer_pos() - { - let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64)); - app.squiggles.menu_word = word; + if !app.anim_preview_active() { + overlays(app, ui, &painter, xf, &t); + ime_output(app, ui, xf); + media_bar(app, ui, xf); + gif_buttons(app, ui, xf, &gifs); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let word = app.session.active().and_then(|st| app.squiggles.word_at(st, xf.to_slide(p), 4.0 / xf.scale.max(0.01) as f64)); + app.squiggles.menu_word = word; + } + context_menu(app, &resp); } - context_menu(app, &resp); } /// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's @@ -383,14 +420,18 @@ fn master_tex( visible: deckcraft_render::Region, ) -> Option<(egui::TextureHandle, deckcraft_render::Region)> { let scale = px.0 as f64 / doc.slide_size.width.max(1.0); - let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible); + let region = crate::textures::fit_texture( + deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible), + visible, + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_layout( doc, master, layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) + Some((crate::textures::upload_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) { @@ -436,15 +477,259 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag()); + let id = ui.id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); + } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } +} + +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { if crate::eyedropper::canvas(app, ui, resp, xf) { return; } + if app.anim_preview_active() { + // A click on the slide ends the preview, then selects as usual. + if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { + app.stop_anim_preview(); + } else { + return; + } + } let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } let p = xf.to_slide(pos); - let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; + let pressure = reported_pressure(ui); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; let primary = ui.input(|i| i.pointer.primary_pressed()); if resp.hovered() && !app.session.tool.dragging() { let _ = app.session.pointer(ev(PointerKind::Move)); @@ -481,7 +766,9 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, 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()) { egui::CursorIcon::Text @@ -489,8 +776,12 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { egui::CursorIcon::Move } } - (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, - _ => egui::CursorIcon::Default, + (_, 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, + None => egui::CursorIcon::Default, + }, }; if resp.hovered() { ui.ctx().set_cursor_icon(cursor); @@ -598,23 +889,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let q = xf.to_screen(a * hd.pos); painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } } } - // 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) { @@ -631,30 +915,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 { @@ -708,13 +977,60 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To if app.session.tool.ink_preview.len() > 1 && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind { - let pts: Vec = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + let pts = &app.session.tool.ink_preview; + let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); let c = theme::to_color32(*color); let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; - painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + if legacy || mode == "highlighter" { + // One line, so see-through highlighter ink doesn't darken where segments meet. + let line: Vec = pts.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + painter.add(egui::Shape::line(line, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + } else { + for pair in pts.windows(2) { + let [q0, q1] = pair else { continue }; + let a = deckcraft_engine::model::ink_sample_width(*width, q0.2, legacy); + let b = deckcraft_engine::model::ink_sample_width(*width, q1.2, legacy); + let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; + let p0 = xf.to_screen(Point::new(q0.0, q0.1)); + let p1 = xf.to_screen(Point::new(q1.0, q1.1)); + painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); + } + } } } +/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. +fn reported_pressure(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), phase, .. } + if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => + { + Some(f.clamp(0.0, 1.0)) + } + _ => None, + }) + }) +} + +/// 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 { + 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 { @@ -900,6 +1216,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("shape.editPoints", json!({"on": true})); ui.close(); } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); @@ -917,6 +1241,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(); @@ -951,6 +1283,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); if ui.button("Reset Slide").clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); @@ -967,6 +1312,42 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { }); } +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -975,6 +1356,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> { @@ -1002,8 +1384,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. @@ -1058,4 +1442,110 @@ mod tests { h.run_steps(2); 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); + assert!((step - 36.0).abs() < 1e-3, "{step}"); + assert!(super::grid_line_step(6.0, 1.0).is_some()); + assert!(super::grid_line_step(0.0, 1.0).is_none()); + assert!(super::grid_line_step(f64::NAN, 1.0).is_none()); + assert!(super::grid_line_step(1_000.0, 1.0).is_none()); + assert!(super::grid_line_step(6.0, 0.0).is_none()); + assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); + } } + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/ui-egui/src/compare_tests.rs b/crates/ui-egui/src/compare_tests.rs new file mode 100644 index 00000000..9108b4de --- /dev/null +++ b/crates/ui-egui/src/compare_tests.rs @@ -0,0 +1,61 @@ +//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide. +fn harness() -> (Harness<'static, SlideApp>, usize) { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": n - 2})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, n) +} + +#[test] +fn pane_opens_lists_changes_and_accept_all_applies_them() { + let (mut h, n) = harness(); + assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane"); + assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some()); + assert!(h.query_by_label("Slide 2 deleted").is_some()); + assert!(h.query_by_label_contains("Slide inserted after").is_some()); + h.get_by_label_contains("Accept All").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added"); + assert!(h.query_by_label("No differences left.").is_some()); + h.get_by_label("End Review").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().compare.is_none()); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let (mut h, _) = harness(); + // Review tab. + h.state_mut().ui.tab = "Review".into(); + h.run_steps(4); + h.render().unwrap().save("/tmp/compare.png").unwrap(); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 09379b50..8f474485 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -3,8 +3,8 @@ //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): //! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI) //! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect` -//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in -//! slide points (same path as the mouse) +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, pressure?}], mods}`: drive the active tool in +//! slide points (same path as the mouse; `pressure` is `0..=1` when a pen reported one) //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine //! - `ui.tool.select {tool, preset?}` //! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer @@ -83,6 +83,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value { let now = ctx.input(|i| i.time); s.showing_value(n, aspect, ctx.content_rect(), now) }), + "animPreview": app.anim_preview_report().map(|(slide, time, duration)| json!({"slide": slide, "time": time, "duration": duration})), "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::>(), "status": app.status.as_ref().map(|s| s.0.clone()), "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, @@ -132,7 +133,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> return err("pointer events need x and y"); }; let mods = e.get("mods").map(mods_from).unwrap_or(base); - match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) { + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64, pressure }) { Ok(v) => last = v, Err(e) => return err(e), } @@ -141,6 +143,10 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.key" => { let Some(k) = s("key") else { return err("missing `key`") }; + if app.anim_preview_active() && app.show.is_none() && matches!(k.to_ascii_lowercase().as_str(), "escape" | "esc") { + app.stop_anim_preview(); + return ok(json!({"preview": false})); + } if let Some(show) = app.show.as_ref() && !show.ended && let Some(key) = key_from(k) @@ -236,7 +242,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }; } if let Some(z) = p.get("zoom") { - app.ui.zoom = z.as_f64().map(|v| v as f32); + app.ui.zoom = z + .as_f64() + .map(|v| v as f32) + .filter(|v| v.is_finite() && *v > 0.0) + .map(|v| v.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); } if let Some(d) = p.get("dark").and_then(Value::as_bool) { let _ = app.run("view.dark", json!({"on": d})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9ca9839a..30fddc94 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Paragraph, Tabs, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). use deckcraft_color::SchemeSlot; @@ -45,9 +45,12 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", + "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", "autocorrect" => "AutoCorrect", + "gridOptions" => "Grid Options", "export" => "Export", "symbol" => "Symbol", "photoAlbum" => "Photo Album", @@ -56,6 +59,7 @@ fn title(id: &str) -> &'static str { "dateTime" => "Date and Time", "spelling" => "Spelling", "pasteSpecial" => "Paste Special", + "find" => "Find and Replace", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", @@ -101,8 +105,17 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { } close = body(app, ui, &mut d); }); - if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if open && !close && !escape { app.dialog = Some(d); + } else if app.dialog.is_none() + && let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned() + { + // Tabs… opened from Paragraph: closing it (OK, Cancel, Esc or the window) returns there. + let id = d.params.get("returnId").and_then(Value::as_str).unwrap_or("paragraph"); + let mut back = Dialog::new(id); + back.params = ret; + app.dialog = Some(back); } } @@ -1495,22 +1508,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - "paragraph" => { - field(ui, d, "Before text (in):", "left", "0"); - field(ui, d, "Special — hanging (in):", "hang", "0"); - field(ui, d, "Spacing before (pt):", "before", "0"); - field(ui, d, "Spacing after (pt):", "after", "0"); - field(ui, d, "Line spacing (lines):", "lines", "1.0"); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "format.paragraph", - json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}), - ); - } - ok || cancel - } + "font" => font_dialog(app, ui, d), + "paragraph" => paragraph_dialog(app, ui, d), + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -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 @@ -1689,6 +1706,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "find" => find_dialog(app, ui, d), "spelling" => { // Words skipped with Ignore All while the dialog is open, one per line. let ignored = d.get("ignore", ""); @@ -2084,6 +2102,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, "OK"); ok || cancel } + "gridOptions" => grid_options(app, ui, d), "preferences" => { ui.label(egui::RichText::new("General").font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; @@ -2456,6 +2475,43 @@ fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { cancel } +fn format_spacing(v: f64) -> String { + if !v.is_finite() { + return "6".into(); + } + if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } +} + +/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one +/// `view.gridOptions` call; Cancel leaves the presentation as it was. +fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); + let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); + let spacing_default = format_spacing(app.session.prefs.grid_spacing); + field(ui, d, "Spacing (pt):", "spacing", &spacing_default); + ui.add_space(6.0); + ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); + let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let spacing = num(d, "spacing", app.session.prefs.grid_spacing); + match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { + Ok(_) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} + fn pretty_equation(s: &str) -> String { let sup = |c: char| match c { '0' => '⁰', @@ -2575,6 +2631,589 @@ pub fn selected_language(app: &SlideApp) -> (String, bool) { (props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true)) } +fn seed_once(d: &mut Dialog, key: &str, val: String) { + d.fields.entry(key.to_string()).or_insert(val); +} + +fn json_text(v: Option<&Value>, default: &str) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()), + Some(Value::Bool(true)) => "1".into(), + Some(Value::Bool(false)) => "0".into(), + _ => default.to_string(), + } +} + +fn color_label(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(), + _ => String::new(), + } +} + +fn seed_font_fields(d: &mut Dialog) { + seed_once(d, "family", json_text(d.params.get("family"), "")); + seed_once(d, "size", json_text(d.params.get("size"), "18")); + seed_once(d, "bold", json_text(d.params.get("bold"), "0")); + seed_once(d, "italic", json_text(d.params.get("italic"), "0")); + seed_once(d, "underline", json_text(d.params.get("underline"), "none")); + seed_once(d, "strike", json_text(d.params.get("strike"), "none")); + seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); + seed_once(d, "caps", json_text(d.params.get("caps"), "none")); + seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); + let shown = d.params.get("color").map(color_label).unwrap_or_default(); + seed_once(d, "color", shown.clone()); + seed_once(d, "colorSeed", shown); +} + +fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, ""); + let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(cur == *v, *l).clicked() { + d.fields.insert((*key).into(), (*v).into()); + } + } + }); +} + +fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { + let mut v = d.get(key, ""); + let l = ui.label(label); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id); + d.fields.insert(key.into(), v.clone()); + v +} + +fn finite_pt(text: &str) -> Option { + text.trim().trim_end_matches("pt").trim().parse::().ok().filter(|v| v.is_finite()) +} + +/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. +fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_font_fields(d); + let mut fams = crate::ribbon::font_families(app); + let family = d.get("family", ""); + if !family.is_empty() && !fams.iter().any(|f| f == &family) { + fams.insert(0, family.clone()); + } + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui( + ui, + |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(family == *f, f).clicked() { + d.fields.insert("family".into(), f.clone()); + } + } + }); + }, + ); + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Size", "size", 64.0); + ui.add_space(16.0); + check(ui, d, "Bold", "bold", false); + check(ui, d, "Italic", "italic", false); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + choice( + ui, + d, + "font_dialog_underline", + "underline", + &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], + ); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "Color", "color", 110.0); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + ui.add_space(8.0); + ui.label("Caps"); + choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + let mut spacing = d.get("spacing", "0"); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let mut picked = None; + egui::ComboBox::from_id_salt("font_dialog_spacing") + .selected_text(match spacing.as_str() { + "-3" => "Very tight", + "-1.5" => "Tight", + "0" => "Normal", + "3" => "Loose", + "6" => "Very loose", + other => other, + }) + .width(120.0) + .show_ui(ui, |ui| { + for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { + if ui.selectable_label(spacing == v, l).clicked() { + picked = Some(v.to_string()); + } + } + }); + let _ = edited; + if let Some(v) = picked { + d.fields.insert("spacing".into(), v); + } else { + d.fields.insert("spacing".into(), spacing); + } + }); + let bold = d.get("bold", "0") == "1"; + let italic = d.get("italic", "0") == "1"; + let underline = d.get("underline", "none"); + let strike = d.get("strike", "none"); + let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; + let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + if bold { + sample = sample.strong(); + } + if italic { + sample = sample.italics(); + } + if underline != "none" { + sample = sample.underline(); + } + if strike != "none" { + sample = sample.strikethrough(); + } + ui.add_space(6.0); + ui.label(sample); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let family = d.get("family", ""); + let family = family.trim(); + if family.is_empty() { + app.set_status("Choose a font"); + return false; + } + let Some(size) = finite_pt(&d.get("size", "")) else { + app.set_status("Font size must be a number"); + return false; + }; + let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { + app.set_status("Character spacing must be a number"); + return false; + }; + let mut params = json!({ + "family": family, + "size": size, + "bold": d.get("bold", "0") == "1", + "italic": d.get("italic", "0") == "1", + "underline": d.get("underline", "none"), + "strike": d.get("strike", "none"), + "baseline": d.get("baseline", "none"), + "caps": d.get("caps", "none"), + "spacing": spacing, + }); + let color = d.get("color", ""); + let seed = d.get("colorSeed", ""); + if color == seed { + if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { + params["color"] = c.clone(); + } + } else if !color.trim().is_empty() { + params["color"] = json!(color.trim()); + } + let _ = app.run("format.fontDialog", params); + } + ok || cancel +} + +fn pt_text(v: Option<&Value>, default: f64) -> String { + match v.and_then(Value::as_f64).filter(|n| n.is_finite()) { + Some(n) => crate::widgets::fmt_num(n, ""), + None => crate::widgets::fmt_num(default, ""), + } +} + +fn inches_text(v: Option<&Value>) -> String { + crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "") +} + +fn seed_paragraph_fields(d: &mut Dialog) { + seed_once(d, "align", json_text(d.params.get("align"), "left")); + seed_once(d, "before", inches_text(d.params.get("indentLeft"))); + seed_once(d, "after", inches_text(d.params.get("indentRight"))); + seed_once(d, "special", json_text(d.params.get("special"), "none")); + seed_once(d, "by", inches_text(d.params.get("specialBy"))); + seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); + seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); +} + +fn paragraph_return_params(d: &mut Dialog) -> Value { + let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; + let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; + let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; + let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); + let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); + let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); + let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); + let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); + let v = json!({ + "align": d.get("align", "left"), + "indentLeft": before, + "indentRight": after, + "special": d.get("special", "none"), + "specialBy": by, + "spaceBefore": space_before, + "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfter": space_after, + "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "lineKind": d.get("lineKind", "multiple"), + "lineValue": line_value, + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + }); + v +} + +fn line_preset_label(kind: &str, value: &str) -> &'static str { + if kind == "exactly" { + "Exactly" + } else { + match value { + "1" => "Single", + "1.5" => "1.5 lines", + "2" => "Double", + _ => "Multiple", + } + } +} + +/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. +fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_paragraph_fields(d); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + choice( + ui, + d, + "paragraph_align", + "align", + &[ + ("left", "Left"), + ("center", "Center"), + ("right", "Right"), + ("justify", "Justify"), + ("justLow", "Justify low"), + ("distributed", "Distributed"), + ], + ); + }); + ui.label(egui::RichText::new("Indentation").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Before text", "before", 64.0); + ui.label("in"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "After text", "after", 64.0); + ui.label("in"); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "By", "by", 64.0); + ui.label("in"); + }); + ui.label(egui::RichText::new("Spacing").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); + ui.label("pt"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); + ui.label("pt"); + }); + let mut picked: Option<(&'static str, Option<&'static str>)> = None; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let kind = d.get("lineKind", "multiple"); + let value = d.get("lineValue", "1"); + egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } + } + }); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "At", "lineValue", 64.0); + }); + if let Some((kind, value)) = picked { + d.fields.insert("lineKind".into(), kind.into()); + if let Some(v) = value { + d.fields.insert("lineValue".into(), v.into()); + } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { + d.fields.insert("lineValue".into(), "18".into()); + } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { + d.fields.insert("lineValue".into(), "1".into()); + } + } + let mut open_tabs = false; + ui.horizontal(|ui| { + if ui.button("Tabs…").clicked() { + open_tabs = true; + } + }); + let (ok, cancel) = buttons(ui, "OK"); + if open_tabs { + let mut tabs = Dialog::new("tabs"); + tabs.params = json!({ + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + "returnId": "paragraph", + "returnParams": paragraph_return_params(d), + }); + app.dialog = Some(tabs); + return true; + } + if ok { + let Some(before_in) = finite_pt(&d.get("before", "")) else { + app.set_status("Indent before text must be a number of inches"); + return false; + }; + let Some(after_in) = finite_pt(&d.get("after", "")) else { + app.set_status("Indent after text must be a number of inches"); + return false; + }; + let Some(by_in) = finite_pt(&d.get("by", "")) else { + app.set_status("Special indent must be a number of inches"); + return false; + }; + let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { + app.set_status("Spacing before must be a number"); + return false; + }; + let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { + app.set_status("Spacing after must be a number"); + return false; + }; + let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { + app.set_status("Line spacing must be a number"); + return false; + }; + let mut params = json!({ + "align": d.get("align", "left"), + "indentLeft": before_in * 72.0, + "indentRight": after_in * 72.0, + "special": d.get("special", "none"), + "specialBy": by_in * 72.0, + "spaceBefore": space_before, + "spaceAfter": space_after, + "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + }); + if d.get("lineKind", "multiple") == "exactly" { + params["lineSpacingPt"] = json!(line_value); + } else { + params["lineSpacing"] = json!(line_value); + } + if app.run("format.paragraphDialog", params).is_err() { + return false; + } + } + ok || cancel +} + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); + } + } + ok || cancel +} + +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + field(ui, d, "Find what:", "text", ""); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} + +#[cfg(test)] +#[path = "font_dialog_tests.rs"] +mod font_dialog_tests; + #[cfg(test)] #[path = "size_position_tests.rs"] mod size_position_tests; @@ -2583,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 { + 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::>()); + } + Ok(p) +} + #[cfg(test)] #[path = "theme_colors_tests.rs"] mod theme_colors_tests; @@ -2625,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, &[]); @@ -2818,3 +3577,7 @@ mod trim_media_tests { #[cfg(test)] #[path = "spelling_tests.rs"] mod spelling_tests; + +#[cfg(test)] +#[path = "paragraph_dialog_tests.rs"] +mod paragraph_dialog_tests; diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index 4445550d..dfff3d4b 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -164,7 +164,7 @@ fn ensure_texture( slide, &RenderOpts { scale: w as f64 / doc.slide_size.width.max(1.0), size: Some((w, h)), edit: true, threads: 0, ..Default::default() }, ); - let tex = ctx.load_texture("doc-window-slide", crate::textures::to_color_image(&img, grayscale), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ctx, "doc-window-slide", crate::textures::to_color_image(&img, grayscale)); if let Some(win) = app.doc_window.as_mut() { win.key = Some(key); win.tex = Some(tex.clone()); diff --git a/crates/ui-egui/src/font_dialog_tests.rs b/crates/ui-egui/src/font_dialog_tests.rs new file mode 100644 index 00000000..06ccfc02 --- /dev/null +++ b/crates/ui-egui/src/font_dialog_tests.rs @@ -0,0 +1,65 @@ +//! The Font dialog: Home › Font › Font… and Cmd+T open it, OK applies one undo step, Cancel does not. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + // A selected shape, not a caret: the dialog then formats the text that is already there. + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn bold_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.bold) +} + +#[test] +fn font_button_applies_bold_from_the_dialog() { + let mut h = harness(); + h.get_by_label("Font…").click(); + // The opening frame ignores the click that summoned the window; the next one is live. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font")); + // A pointer click on this checkbox does not register through kittest's per-event frames; + // the accessibility click does, and it is the same toggle a pointer click runs. + h.get_by_label("Bold").click_accesskit(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(bold_of(&h), Some(true)); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Font…"); +} + +#[test] +fn command_t_opens_the_font_dialog_and_cancel_keeps_the_text() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T); + h.run_steps(1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"), "status {:?}", h.state().status); + h.get_by_label("Cancel").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(bold_of(&h), None); +} diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/guides_tests.rs new file mode 100644 index 00000000..4180e503 --- /dev/null +++ b/crates/ui-egui/src/guides_tests.rs @@ -0,0 +1,69 @@ +//! Drawing guides driven with the mouse: View › Guides adds the centre guides, dragging one +//! moves it (with its distance from the centre shown), dropping it off the slide deletes it. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn turn_guides_on_drag_one_and_drop_it_off_the_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().run("view.guides", json!({"on": true})).unwrap(); + h.run_steps(2); + let guides = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.guides.clone(); + assert_eq!(guides(&h).len(), 2, "the two centre guides"); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + // Grab the vertical guide away from the horizontal one and drag it left. + let start = at(480.0, 100.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(480.0 - 18.0 * i as f32, 100.0))); + h.run_steps(1); + } + assert_eq!(h.state().session.tool.guide_drag, Some((0, 300.0))); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/guides.png"); + } + h.event(egui::Event::PointerButton { pos: at(300.0, 100.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h)[0].pos, 300.0); + + // Off the top of the slide with the horizontal guide: deleted. + let g = at(700.0, 270.0); + h.hover_at(g); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: g, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(700.0, 270.0 - 30.0 * i as f32))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: at(700.0, -30.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h).len(), 1); + assert!(guides(&h)[0].vertical); + + // Hidden guides stay in the file but aren't drawn or grabbed. + h.state_mut().run("view.guides", json!({"on": false})).unwrap(); + h.run_steps(2); + assert!(!h.state().session.tool.guides_visible); + assert_eq!(guides(&h).len(), 1); +} diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index 911a2d1a..c9bc7dab 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -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); diff --git a/crates/ui-egui/src/inbox_tests.rs b/crates/ui-egui/src/inbox_tests.rs new file mode 100644 index 00000000..dc657173 --- /dev/null +++ b/crates/ui-egui/src/inbox_tests.rs @@ -0,0 +1,188 @@ +//! #414: a browser file pick finishes on the document, slide and operation that started it. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use deckcraft_model::ShapeKind; +use serde_json::json; + +use super::{Inbox, InboxEvent, InboxFile, Services, SlideApp}; +use crate::Session; + +fn png() -> Vec { + // 1×1 RGB PNG. Enough for `insert.picture` to accept it. + const HEX: &str = + "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c49444154789c63f8cfc0000003010100c9fe92ef0000000049454e44ae426082"; + (0..HEX.len()).step_by(2).map(|i| u8::from_str_radix(&HEX[i..i + 2], 16).expect("hex")).collect() +} + +fn wav() -> Vec { + let data = [0u8; 8]; + let mut b = b"RIFF".to_vec(); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&8000u32.to_le_bytes()); + b.extend_from_slice(&16000u32.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b +} + +fn setup() -> (SlideApp, Inbox, Arc>>) { + let inbox: Inbox = Inbox::default(); + let started = Arc::new(Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + inbox: Some(inbox.clone()), + open_async: Some(Box::new(move |purpose, id| { + record.lock().expect("request log").push((purpose.to_string(), id)); + })), + ..Default::default() + }; + (SlideApp::new(Session::with_new(), services), inbox, started) +} + +fn deliver(inbox: &Inbox, id: u64, name: &str, bytes: Vec) { + inbox.lock().expect("inbox").push(InboxEvent::File(InboxFile { id, name: name.into(), bytes })); +} + +fn pictures(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize { + let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document"); + let i = st.doc.slide_index(slide).expect("slide"); + st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count() +} + +fn audios(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize { + let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document"); + let i = st.doc.slide_index(slide).expect("slide"); + st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Media(ref m) if !m.video)).count() +} + +/// Two picks finish in reverse order, after the user has changed slide and document. +/// Each file uses the operation that was requested and lands on the slide that asked. +#[test] +fn late_picker_files_keep_their_document_slide_and_operation() { + let (mut app, inbox, started) = setup(); + let uid_a = app.session.active().unwrap().uid; + let slide0 = app.session.active().unwrap().current_slide().unwrap().id; + app.session.execute("slide.new", &json!({})).unwrap(); + let slide1 = app.session.active().unwrap().current_slide().unwrap().id; + assert_ne!(slide0, slide1); + + // Audio was asked for on slide 1; the picture and the outline on slide 0. + app.pick_and_insert("audio", "insert.audio"); + app.session.execute("slide.go", &json!({"id": slide0.0})).unwrap(); + app.pick_and_insert("picture", "insert.picture"); + app.pick_and_open(); + + app.session.execute("file.new", &json!({})).unwrap(); + let uid_b = app.session.active().unwrap().uid; + assert_ne!(uid_a, uid_b); + let shapes_b = app.session.active().unwrap().doc.slides[0].shapes.len(); + // A video was requested on B. Delivering a PNG must not insert a picture anywhere. + app.pick_and_insert("video", "insert.video"); + + let log = started.lock().expect("request log").clone(); + assert_eq!(log.iter().map(|(p, _)| p.as_str()).collect::>(), vec!["audio", "picture", "open", "video"]); + let audio_id = log[0].1; + let picture_id = log[1].1; + let outline_id = log[2].1; + let video_id = log[3].1; + + // The video request completes first, while B is still current. A PNG must not become a picture. + deliver(&inbox, video_id, "clip.mp4", png()); + app.drain_inbox(); + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert!(status.contains("not a supported video") || status.contains("no video"), "{status}"); + assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b); + + // The earlier picks then finish in reverse order. + deliver(&inbox, outline_id, "talk.md", b"Brought Later\n".to_vec()); + deliver(&inbox, picture_id, "shot.png", png()); + deliver(&inbox, audio_id, "tone.wav", wav()); + app.drain_inbox(); + + assert_eq!(app.session.active().unwrap().uid, uid_b, "the view stays on the document the user switched to"); + assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b, "the video request must not insert the PNG here"); + assert_eq!(pictures(&app, uid_b, app.session.documents().iter().find(|d| d.uid == uid_b).unwrap().doc.slides[0].id), 0); + + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert_eq!(pictures(&app, uid_a, slide0), 1, "{status}"); + assert_eq!(pictures(&app, uid_a, slide1), 0); + assert_eq!(audios(&app, uid_a, slide1), 1); + assert_eq!(audios(&app, uid_a, slide0), 0); + let a = app.session.documents().iter().find(|d| d.uid == uid_a).unwrap(); + assert!(a.doc.slides.iter().any(|s| s.title() == "Brought Later"), "the outline stays on the document that opened it"); + assert_eq!(a.doc.slides.len(), 3); + // The user had moved to slide 0 of A before switching documents; B is what's on screen. + assert_eq!(app.session.active().unwrap().selection.slide, 0); +} + +/// Closing the presentation, or dismissing the picker, drops the file instead of inserting +/// it into whatever is open now. +#[test] +fn a_closed_or_cancelled_request_does_not_insert_into_the_active_document() { + let (mut app, inbox, started) = setup(); + let slide = app.session.active().unwrap().current_slide().unwrap().id; + app.pick_and_insert("picture", "insert.picture"); + app.session.execute("slide.new", &json!({})).unwrap(); + let kept = app.session.active().unwrap().current_slide().unwrap().id; + app.session.execute("slide.delete", &json!({"ids": [slide.0]})).unwrap(); + app.pick_and_insert("picture", "insert.picture"); + let log = started.lock().expect("request log").clone(); + let deleted_slide = log[0].1; + let dismiss = log[1].1; + + inbox.lock().expect("inbox").push(InboxEvent::Cancelled { id: dismiss }); + deliver(&inbox, dismiss, "late.png", png()); + deliver(&inbox, deleted_slide, "gone.png", png()); + // An id that was never started must not insert either. + deliver(&inbox, 999, "stray.png", png()); + app.drain_inbox(); + + assert_eq!(pictures(&app, app.session.active().unwrap().uid, kept), 0); + let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + assert!(status.contains("deleted"), "{status}"); + + app.session.execute("file.close", &json!({})).unwrap(); + app.session.execute("file.new", &json!({})).unwrap(); + // The deleted-slide request was already consumed. Start another and close before it arrives. + let closed = app.session.active().unwrap().uid; + app.pick_and_insert("audio", "insert.audio"); + let audio_id = started.lock().expect("request log").last().unwrap().1; + app.session.execute("file.close", &json!({})).unwrap(); + app.session.execute("file.new", &json!({})).unwrap(); + let other = app.session.active().unwrap().uid; + assert_ne!(closed, other); + deliver(&inbox, audio_id, "tone.wav", wav()); + app.drain_inbox(); + let st = app.session.documents().iter().find(|d| d.uid == other).unwrap(); + let n = st.doc.slides.iter().flat_map(|s| s.shapes.iter()).filter(|s| matches!(s.kind, ShapeKind::Media(_))).count(); + assert_eq!(n, 0, "a file for a closed presentation is not inserted into the one now open"); + let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + assert!(status.contains("closed"), "{status}"); +} + +/// A drop is bound to the slide under the pointer at drop time, not the slide current when +/// the bytes have been read. +#[test] +fn a_dropped_file_stays_on_the_slide_it_was_dropped_on() { + let (mut app, inbox, _) = setup(); + let uid = app.session.active().unwrap().uid; + let slide0 = app.session.active().unwrap().current_slide().unwrap().id; + let id = app.begin_external_file("drop"); + app.session.execute("slide.new", &json!({})).unwrap(); + let slide1 = app.session.active().unwrap().current_slide().unwrap().id; + deliver(&inbox, id, "shot.png", png()); + app.drain_inbox(); + assert_eq!(app.session.active().unwrap().current_slide().unwrap().id, slide1, "the view stays on the slide the user moved to"); + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert_eq!(pictures(&app, uid, slide0), 1, "{status}"); + assert_eq!(pictures(&app, uid, slide1), 0); +} diff --git a/crates/ui-egui/src/inktoshape_tests.rs b/crates/ui-egui/src/inktoshape_tests.rs new file mode 100644 index 00000000..7a7ebafc --- /dev/null +++ b/crates/ui-egui/src/inktoshape_tests.rs @@ -0,0 +1,60 @@ +//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the +//! mouse, press the button, get a rectangle shape. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn pen_a_rectangle_then_ink_to_shape() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Draw".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Black Pen").click(); + h.run_steps(2); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)]; + h.hover_at(at(200.0, 150.0)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for w in corners.windows(2) { + for i in 1..=12 { + let t = i as f32 / 12.0; + let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + } + h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let has_ink = |h: &Harness<'_, SlideApp>| { + h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })) + }; + assert!(has_ink(&h), "the pen drew a stroke"); + h.get_by_label("Ink to Shape").click(); + h.run_steps(3); + assert!(!has_ink(&h), "the only stroke became a shape"); + let st = h.state().session.active().unwrap(); + let sh = st.shapes().last().unwrap(); + assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom); + let x = sh.xfrm.unwrap(); + assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}"); + assert_eq!(st.selection.shapes, vec![sh.id]); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ink_to_shape.png"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cda33549..9f13f8ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,10 +18,13 @@ pub mod fillui; pub mod icons; pub mod media; pub mod menus; +pub mod notes; pub mod panes; +mod preview; pub mod qat; pub mod ribbon; pub mod selpane; +pub mod settings; pub mod show; pub mod sorter; pub mod squiggles; @@ -33,9 +36,11 @@ pub mod toast; pub mod uifonts; pub mod widgets; +use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; -use deckcraft_engine::{Mods, Session, UiRequest}; +use deckcraft_engine::{Mods, Session, Target, UiRequest}; +use deckcraft_model::{LayoutId, MasterId, SlideId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -44,10 +49,152 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; -pub type OpenAsyncFn = Box; +/// `(purpose, request id)`. The host shows a picker and later pushes an [`InboxEvent`] +/// carrying that same id. Completing a cancelled or unknown id does nothing: the file is +/// not applied to whichever document happens to be active. +pub type OpenAsyncFn = Box; pub type DownloadFn = Box; -/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). -pub type Inbox = std::sync::Arc)>>>; + +/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. +#[derive(Clone, Debug)] +pub struct InboxFile { + /// Cancellation identity. A file whose id was cancelled, or never started, is ignored. + pub id: u64, + pub name: String, + pub bytes: Vec, +} + +/// What the host delivers through [`Inbox`] after an asynchronous read. +#[derive(Clone, Debug)] +pub enum InboxEvent { + File(InboxFile), + /// The picker was dismissed or the file could not be read. `id` will not be applied. + Cancelled { + id: u64, + }, +} + +/// Files delivered asynchronously by the host (web file picker, dropped files). +pub type Inbox = std::sync::Arc>>; + +/// The operation the user asked for. Kept with the request so a late completion does not +/// guess a command from the file name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilePurpose { + Open, + 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, +} + +/// Where in a presentation a file should land. Ids, not indices: slides can move while +/// the browser is still reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilePlace { + Slide(SlideId), + Master(MasterId), + Layout { + master: MasterId, + layout: LayoutId, + }, + /// The handout or notes master (one per presentation, so no id is needed). + NotesPage(Target), + /// The document was open but had no current slide, master or layout. + Unspecified, +} + +/// The presentation (and place in it) captured when the request started. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileTarget { + /// Nothing was open. An insert makes its own document rather than using one opened + /// while the file was being read. + Absent, + Document { + doc: u64, + place: FilePlace, + }, +} + +struct PendingFile { + id: u64, + purpose: FilePurpose, + target: FileTarget, +} + +struct LocatedPlace { + target: Target, + slide: Option, +} + +fn purpose_of(purpose: &str) -> FilePurpose { + match purpose { + "open" => FilePurpose::Open, + "picture" => FilePurpose::Picture, + "audio" => FilePurpose::Audio, + "video" => FilePurpose::Video, + _ => FilePurpose::Infer, + } +} + +fn is_presentation_name(lower: &str) -> bool { + lower.ends_with(".deckcraft") + || lower.ends_with(".slidecraft") + || lower.ends_with(".pptx") + || lower.ends_with(".potx") + || lower.ends_with(".ppsx") +} + +/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. +fn open_command(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + Some("file.openBytes") + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + Some("slide.fromOutline") + } else { + None + } +} + +/// A drop has no requested operation, so the name and bytes decide: a presentation opens, +/// then a recognised picture, sound or video is inserted, then an outline becomes slides. +fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + return Some("file.openBytes"); + } + let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); + if ct.starts_with("image/") { + Some("insert.picture") + } else if ct.starts_with("audio/") { + Some("insert.audio") + } else if ct.starts_with("video/") { + Some("insert.video") + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + Some("slide.fromOutline") + } else { + None + } +} + +fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { + let cmd = match purpose { + 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)] @@ -58,13 +205,18 @@ pub struct Services { pub pick_save: Option, pub read: Option, pub write: Option, - /// Asynchronous open dialog (web): the chosen file arrives through `inbox`. + /// Asynchronous open dialog (web). The callback receives the purpose and the request id + /// from [`SlideApp::begin_external_file`]; the chosen file arrives through `inbox`. pub open_async: Option, /// Hand bytes to the user as a named file (browser download). pub download: Option, pub inbox: Option, /// Image on the system clipboard (PNG bytes), when the host can read one. pub clipboard_image: Option Option>>>, + /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`). + pub clipboard_html: Option Option>>, + /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. + pub clipboard_set_html: Option bool>>, /// Audio output for media playback (cpal on desktop); without one media plays silently. pub audio_out: Option>, } @@ -167,6 +319,41 @@ fn system_clipboard_text(t: &str) -> String { if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } } +/// The editor's zoom range (1 = 100 %). +pub const ZOOM_RANGE: std::ops::RangeInclusive = 0.1..=4.0; + +impl UiState { + /// The state with every value usable: `ui.json` is a file on disk, and a damaged or + /// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the + /// renderer for a texture larger than memory at start-up; panel sizes, scales and names are + /// brought into range the same way, and the recent list is cut to its usual length. + pub fn sanitized(mut self) -> UiState { + let d = UiState::default(); + let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end())); + self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom); + self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale); + self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height); + self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width); + self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width); + let short = |s: &String| s.len() <= 64; + if !short(&self.tab) { + self.tab = d.tab; + } + if !short(&self.format_tab) { + self.format_tab = d.format_tab; + } + self.pane = self.pane.filter(short); + self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); + self.recent.dedup(); + self.recent.truncate(RECENT_MAX); + self + } +} + +/// How many recent files File › Open and Home list. +pub const RECENT_MAX: usize = 12; + /// UI-only commands (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.normal", "Normal", None, "{}"), @@ -178,12 +365,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.ruler", "Ruler", None, "{on?: bool}"), ("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"), ("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"), + ("view.gridOptions", "Grid Options…", None, "{spacing?: pt, snap?: bool, smartGuides?: bool, gridlines?: bool, guides?: bool}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Set Zoom", None, "{percent}"), ("view.zoomDialog", "Zoom…", None, "{}"), ("view.fit", "Fit to Window", None, "{}"), ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|smartart|null}"), + ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), @@ -192,6 +381,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("show.start", "Slide Show", Some("F5"), "{from?: index, custom?: name}"), ("show.presenter", "Presenter View", None, "{on?: bool}"), ("show.end", "End Show", None, "{}"), + ("animation.preview", "Preview Animations", None, "{on?: bool}"), ("app.home", "Home", None, "{on?}"), ("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"), ("app.closePresentation", "Close", Some("Cmd+W"), "{}"), @@ -207,14 +397,18 @@ 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, "{}"), ("app.exportDialog", "Export…", None, "{}"), ("app.browseThemeDialog", "Browse for Themes…", None, "{}"), + ("app.compareDialog", "Compare…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), ("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"), ("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"), + ("app.find", "Find…", Some("Cmd+F"), "{}"), + ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -224,6 +418,18 @@ pub struct Perf { pub fps: f64, } +/// Playhead for the in-canvas animation preview. +struct AnimPreview { + uid: u64, + slide: usize, + /// `Arc` address of the slide when preview started. An edit replaces the slide and stops playback. + slide_ptr: usize, + /// egui time when the first frame latched the clock (`None` until then). + started: Option, + elapsed: f64, + timeline: deckcraft_anim::Timeline, +} + pub struct SlideApp { /// Table Design's border pen (`color`, `width`, `dash` for `table.borders`). pub table_pen: serde_json::Value, @@ -235,7 +441,11 @@ pub struct SlideApp { /// Spelling squiggles, checked on a worker thread. pub squiggles: squiggles::Squiggles, pub dialog: Option, + /// The word looked up in the Thesaurus pane. + pub thesaurus_query: String, pub show: Option, + /// Current slide's animations playing on the editor canvas (not the full-screen show). + anim_preview: Option, /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, pub status: Option<(String, f64)>, @@ -275,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). @@ -290,6 +500,27 @@ pub struct SlideApp { pub(crate) slides_focused: bool, /// A V key press or a Paste event arrived since the last V release (see `keyboard`). v_seen: 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, + /// 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. + next_file_id: u64, + /// Picks and drops whose bytes have not arrived yet. + pending_files: Vec, +} + +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } } impl SlideApp { @@ -304,7 +535,9 @@ impl SlideApp { control_rx: None, squiggles: Default::default(), dialog: None, + thesaurus_query: String::new(), show: None, + anim_preview: None, media: media::MediaHost::new(audio_out), status: None, toast: None, @@ -330,7 +563,7 @@ 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(), @@ -338,6 +571,10 @@ impl SlideApp { sorter: sorter::State::default(), slides_focused: false, v_seen: false, + recent_checked: HashMap::new(), + twisting: false, + next_file_id: 1, + pending_files: Vec::new(), } } @@ -356,6 +593,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 { + 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 { @@ -450,6 +737,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; @@ -482,6 +770,7 @@ impl SlideApp { return any; } "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) @@ -547,7 +836,60 @@ impl SlideApp { "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; @@ -564,6 +906,19 @@ impl SlideApp { } "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } "view.pane" => { let pane = p.get("pane").and_then(Value::as_str).map(String::from); self.ui.pane = @@ -600,6 +955,13 @@ impl SlideApp { self.show = None; self.media.stop_owned(media::Owner::Show); } + "animation.preview" => { + if p.get("on").and_then(Value::as_bool) == Some(false) { + self.anim_preview = None; + return Ok(json!({"playing": false})); + } + return self.start_anim_preview(); + } "app.home" => { let on = self.flag(p, self.backstage_open()); if on { @@ -641,6 +1003,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"), @@ -658,6 +1021,23 @@ impl SlideApp { } } "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -683,6 +1063,7 @@ impl SlideApp { } pub fn start_show(&mut self, from: usize, reading: bool) { + self.anim_preview = None; self.media.stop_owned(media::Owner::Editor); if let Some(d) = self.session.active() { self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading)); @@ -700,6 +1081,84 @@ impl SlideApp { } } + /// The current slide's animations are playing on the editor canvas. + pub fn anim_preview_active(&self) -> bool { + self.anim_preview.is_some() + } + + /// `(slide, seconds played, sequence length)` while a preview is running. + pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { + self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) + } + + pub fn stop_anim_preview(&mut self) { + self.anim_preview = None; + } + + /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. + pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { + self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) + } + + /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence + /// (plus a short hold) has finished. + pub fn tick_anim_preview(&mut self, now: f64) -> bool { + let Some(p) = self.anim_preview.as_ref() else { return false }; + let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); + let alive = self.show.is_none() + && self.session.active().is_some_and(|d| { + d.uid == uid + && d.selection.target == deckcraft_engine::Target::Slides + && d.selection.slide == slide + && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) + }); + if !alive { + self.anim_preview = None; + return false; + } + let Some(p) = self.anim_preview.as_mut() else { return false }; + let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; + let start = *p.started.get_or_insert(now); + let elapsed = (now - start).max(0.0); + if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { + self.anim_preview = None; + return false; + } + p.elapsed = elapsed; + true + } + + fn start_anim_preview(&mut self) -> Result { + let (uid, slide, ptr, timeline) = { + let Some(d) = self.session.active() else { + return Err("no presentation is open".into()); + }; + if d.selection.target != deckcraft_engine::Target::Slides { + return Err("animation preview plays the current slide".into()); + } + let slide = d.selection.slide; + let Some(slide_arc) = d.doc.slides.get(slide) else { + return Err("no slide to preview".into()); + }; + let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; + let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); + (d.uid, slide, ptr, timeline) + }; + if timeline.is_empty() { + self.anim_preview = None; + self.set_status("This slide has no animations"); + return Ok(json!({"playing": false})); + } + if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { + self.ui.view = ViewMode::Normal; + } + self.close_backstage(); + let duration = timeline.total_duration(); + self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, elapsed: 0.0, timeline }); + self.set_status("Previewing animations"); + Ok(json!({"playing": true, "slide": slide, "duration": duration})) + } + fn pick_and_open(&mut self) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick("open") @@ -707,16 +1166,21 @@ impl SlideApp { { self.notify(e); } - } else if let Some(f) = self.services.open_async.as_mut() { - f("open"); + } else if self.services.open_async.is_some() { + let id = self.begin_external_file("open"); + if let Some(f) = self.services.open_async.as_mut() { + f("open", id); + } } } 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(12); + self.ui.recent.truncate(RECENT_MAX); self.ui.view = ViewMode::Normal; Ok(()) } @@ -753,8 +1217,17 @@ impl SlideApp { self.set_status(e.to_string()); } } - } else if let Some(f) = self.services.open_async.as_mut() { - f(purpose); + } 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); + } } } @@ -837,48 +1310,182 @@ impl SlideApp { fn drain_inbox(&mut self) { let Some(inbox) = self.services.inbox.clone() else { return }; - let files: Vec<(String, Vec)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default(); - for (name, bytes) in files { - self.open_or_insert_bytes(&name, &bytes); + let events = match inbox.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), + }; + for event in events { + match event { + InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), + InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), + } } } - /// A dropped or picked file: presentations open, media is inserted. + /// Remember the open presentation, the slide (or master/layout) and the operation for a + /// file the host will read asynchronously. Returns the cancellation identity the host must + /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the + /// bytes arrive. + pub fn begin_external_file(&mut self, purpose: &str) -> u64 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); + id + } + + /// A dropped or picked file read synchronously: presentations open, media is inserted on + /// the slide that is current now. pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { - let lower = name.to_ascii_lowercase(); - let data = deckcraft_engine::cmd::base64_encode(bytes); - let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); - let cmd = if lower.ends_with(".deckcraft") - || lower.ends_with(".slidecraft") - || lower.ends_with(".pptx") - || lower.ends_with(".potx") - || lower.ends_with(".ppsx") - { - "file.openBytes" - } else if ct.starts_with("image/") { - "insert.picture" - } else if ct.starts_with("audio/") { - "insert.audio" - } else if ct.starts_with("video/") { - "insert.video" - } else if lower.ends_with(".txt") || lower.ends_with(".md") { - "slide.fromOutline" - } else { - self.set_status(format!("{name}: DeckCraft can't open this kind of file")); - return; + let id = self.begin_external_file("drop"); + self.apply_external_file(id, name, bytes); + } + + fn capture_file_target(&self) -> FileTarget { + let Some(d) = self.session.active() else { return FileTarget::Absent }; + let place = match d.selection.target { + Target::Slides => match d.current_slide() { + Some(s) => FilePlace::Slide(s.id), + None => FilePlace::Unspecified, + }, + Target::Master { master } => match d.doc.masters.get(master) { + Some(m) => FilePlace::Master(m.id), + None => FilePlace::Unspecified, + }, + Target::Layout { master, layout } => match d.doc.masters.get(master) { + Some(m) => match m.layouts.get(layout) { + Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, + None => FilePlace::Unspecified, + }, + None => FilePlace::Unspecified, + }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), }; - let p = if cmd == "slide.fromOutline" { - json!({"text": String::from_utf8_lossy(bytes), "markdown": lower.ends_with(".md")}) - } else { - json!({"name": name, "data": data}) - }; - if self.session.active().is_none() && cmd != "file.openBytes" { - let _ = self.session.execute("file.new", &json!({})); + FileTarget::Document { doc: d.uid, place } + } + + /// True when the user is still looking at the place this request captured. + fn viewing_target(&self, target: &FileTarget) -> bool { + let FileTarget::Document { doc, place } = target else { return false }; + let Some(d) = self.session.active() else { return false }; + if d.uid != *doc { + return false; } - match self.session.execute(cmd, &p) { - Err(e) if cmd == "file.openBytes" => self.notify(e.to_string()), - Err(e) => self.set_status(e.to_string()), - Ok(_) => {} + match place { + FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), + FilePlace::Master(id) => { + matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) + } + FilePlace::Layout { master, layout } => { + matches!(d.selection.target, Target::Layout { master: mi, layout: li } + if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) + } + FilePlace::NotesPage(t) => d.selection.target == *t, + FilePlace::Unspecified => false, + } + } + + fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { + let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; + let req = self.pending_files.remove(pos); + let cmd = match command_for(req.purpose, name, bytes) { + Ok(cmd) => cmd, + Err(e) => { + self.set_status(e); + return; + } + }; + let params = if cmd == "slide.fromOutline" { + json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) + } else { + json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) + }; + let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { + self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) + } else { + self.execute_on_target(&req.target, name, cmd, ¶ms) + }; + match result { + Err(e) if cmd == "file.openBytes" => self.notify(e), + Err(e) => self.set_status(e), + Ok(()) => {} + } + self.drain_requests(); + } + + /// Run `cmd` on the document and slide captured for the request, then put the view back + /// if the user has since moved. A missing document or slide cancels the file. + fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { + let FileTarget::Document { doc, place } = target else { + self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; + return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + }; + let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { + return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); + }; + let view = self.session.active_index(); + let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); + self.session.set_active(idx); + let located = match self.locate_place(place) { + Ok(located) => located, + Err(e) => { + self.restore_file_view(view); + return Err(format!("{name} was not applied: {e}")); + } + }; + if let Some(st) = self.session.active_mut() { + st.selection.target = located.target; + if let Some(slide) = located.slide { + st.selection.slide = slide; + st.selection.slides.clear(); + } + st.selection.shapes.clear(); + st.selection.text = None; + st.selection.cells = None; + } + let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + if view == Some(idx) { + if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { + st.selection = sel; + } + } else { + self.restore_file_view(view); + } + result + } + + fn restore_file_view(&mut self, view: Option) { + if let Some(i) = view + && i < self.session.documents().len() + { + self.session.set_active(i); + } + } + + fn locate_place(&self, place: &FilePlace) -> Result { + let d = self.session.doc().map_err(|e| e.to_string())?; + match place { + FilePlace::Slide(id) => { + let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) + } + FilePlace::Master(id) => { + let master = + d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Master { master }, slide: None }) + } + FilePlace::Layout { master, layout } => { + let mi = + d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + let li = d + .doc + .masters + .get(mi) + .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) + .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) + } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), + FilePlace::Unspecified => Err("there was no slide to put it on".into()), } } @@ -935,7 +1542,7 @@ impl SlideApp { } } // On the web, dropped files can only be read asynchronously: the host reads them and - // delivers them through `Services::inbox`. + // delivers them through `Services::inbox`, tagged with the request captured at the drop. #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); @@ -944,6 +1551,7 @@ impl SlideApp { Err(e) => self.set_status(e), } } + canvas::trackpad_twist(self, ctx); } /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open @@ -1068,30 +1676,26 @@ impl SlideApp { } } egui::Event::Ime(ime) => self.ime_event(&ime), - egui::Event::Copy => { - if let Ok(v) = self.session.execute("edit.copy", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(system_clipboard_text(t)); - } - } - egui::Event::Cut => { - if let Ok(v) = self.session.execute("edit.cut", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(system_clipboard_text(t)); - } - } + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => { // Our own marker for objects without text: paste what the session copied. let text = if text == OBJECTS_MARKER { String::new() } else { text }; - // An image on the clipboard (screenshots, copied pictures) becomes a picture. + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let r = match img { Some(png) => self .session .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => self.session.execute("edit.paste", &json!({"text": text})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } }; if let Err(e) = r { self.set_status(e.to_string()); @@ -1104,6 +1708,10 @@ impl SlideApp { // every key after it would go there instead of the slide (#78). ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } + if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { + self.anim_preview = None; + continue; + } let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { continue; @@ -1120,6 +1728,24 @@ impl SlideApp { self.drain_requests(); } + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). + fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { + return; + } + ctx.copy_text(system_clipboard_text(&text)); + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1132,6 +1758,7 @@ impl SlideApp { self.tick_media(&ctx); eyedropper::tick(self, &ctx); if self.show.is_some() { + self.anim_preview = None; show::ui(self, ui); doc_window::show(self, &ctx); self.perf.frame_ms = now_ms() - t0; @@ -1145,6 +1772,10 @@ impl SlideApp { ribbon::show(self, ui); status::status_bar(self, ui); } + let now = ui.input(|i| i.time); + if self.tick_anim_preview(now) { + ctx.request_repaint(); + } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { if self.backstage.is_some() { // File tab: the Backstage view replaces everything below the title bar. @@ -1355,6 +1986,8 @@ mod animpane_tests; #[cfg(test)] mod browsetheme_tests; #[cfg(test)] +mod compare_tests; +#[cfg(test)] mod eyedropper_tests; #[cfg(test)] mod huge_deck_tests; @@ -1365,17 +1998,67 @@ mod replacefonts_tests; #[cfg(test)] mod slide_size_tests; #[cfg(test)] +mod texture_limit_tests; +#[cfg(test)] mod ui_soak_tests; #[cfg(test)] #[path = "datetime_tests.rs"] mod datetime_tests; +#[cfg(test)] +mod inbox_tests; + #[cfg(test)] 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(); + let id = d.selection.shapes[0]; + d.shape(id).unwrap().xfrm.unwrap().rot + } + + /// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; + /// letting go is one undo step. Off the canvas it does nothing. + #[test] + fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { + 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 id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); + let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.run_steps(1); + let canvas = harness.state().canvas_rect.expect("canvas"); + let outside = egui::pos2(1.0, 1.0); + assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); + + harness.input_mut().events.push(egui::Event::PointerMoved(outside)); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); + + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); + harness.run_steps(1); + assert!(harness.state().session.active().unwrap().interaction.is_none()); + harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); + } /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). #[test] @@ -1465,6 +2148,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!("").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() { @@ -1737,6 +2475,79 @@ mod tests { assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); assert!(app.session.active().is_none()); } + + #[test] + fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + } + + #[test] + fn grid_options_dialog_lists_spacing_and_display() { + 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); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + } + fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { @@ -1949,4 +2760,463 @@ mod tests { let st = harness.state().session.active().unwrap(); 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::>(); + 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| 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| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), services), + ); + h.run_steps(2); + h + } + + /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. + #[test] + fn copy_offers_html_and_paste_keeps_bold() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(None)); + let record = seen.clone(); + let services = Services { + clipboard_set_html: Some(Box::new(move |html, plain| { + *record.borrow_mut() = Some((html.to_string(), plain.to_string())); + true + })), + ..Default::default() + }; + let mut h = step_app(services); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + h.event(egui::Event::Text("Hi".into())); + h.run_steps(1); + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let (html, plain) = seen.borrow().clone().expect("html offered"); + assert!(html.contains(""), "{html}"); + assert_eq!(plain, "Hi"); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); + + let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; + let mut h = step_app(services); + h.event(egui::Event::Paste("Hi".into())); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + let body = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .and_then(|sh| sh.text.clone()) + .expect("pasted text"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); + } + + /// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can + /// still paste it here (#182). + #[test] + fn a_textless_shape_copies_as_the_objects_marker() { + let mut h = step_app(Services::default()); + h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let cmds = &h.output().platform_output.commands; + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); + } + + fn redder(px: [u8; 4]) -> i32 { + px[0] as i32 - px[1].max(px[2]) as i32 + } + + /// A fade entrance is hidden at the start of the preview clock and opaque once it has played. + #[test] + fn preview_hides_an_entrance_until_it_plays() { + let mut session = Session::with_new(); + let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let shape = deckcraft_model::ShapeId(text as u32); + let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); + assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); + let mid = para(0.25, 0); + assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); + assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); + + session.execute("animation.clear", &json!({})).unwrap(); + let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); + let paint = |time: Option| { + let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); + let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; + let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; + deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) + }; + let rest = paint(None); + let start = paint(Some(0.0)); + let end = paint(Some(tl.total_duration() + 0.05)); + assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); + assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); + assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); + } + + #[test] + fn animation_preview_plays_on_the_canvas_then_stops() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).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 none = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(none["playing"], false); + assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); + assert!(harness.state().show.is_none()); + + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); + let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(started["playing"], true); + assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); + harness.run_steps(1); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); + let renders = harness.state().textures.canvas_renders; + harness.run_steps(6); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); + assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); + assert!(harness.state().show.is_none()); + + harness.key_press(egui::Key::Escape); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); + assert!(harness.state().anim_preview.is_none()); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + let centre = harness.state().slide_rect.unwrap().center(); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(2); + assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); + + harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + // 400 ms effect plus the hold. + harness.run_steps(30); + assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); + assert!(harness.state().show.is_none()); + } + + fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.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); + // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. + harness.state_mut().ui.zoom = Some(0.4); + harness + } + + fn slide_at(app: &SlideApp) -> usize { + app.session.active().unwrap().selection.slide + } + + /// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a + /// mouse notch. The harness runs one frame per queued event, so they have to share a frame or + /// none of them reaches the page threshold. + fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { + let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + let step = delta / 4.0; + for _ in 0..4 { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: step, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); + } + + #[test] + fn page_flick_uses_the_stronger_axis() { + assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); + assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); + } + + /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. + /// The frames of one gesture do not skip ahead. + #[test] + fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + assert_eq!(slide_at(harness.state()), 0); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); + + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); + } + + #[test] + fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-2.0, -24.0)); + assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); + + harness.state_mut().ui.zoom = Some(3.0); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); + } + + #[test] + fn a_swipe_during_the_show_turns_the_page() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } + + /// The pen is drawing. A flick must not turn the page out from under the stroke. + #[test] + fn the_pen_ignores_a_swipe_during_the_show() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } } diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 85b65ea1..4d65fd40 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -287,8 +287,8 @@ impl MediaHost { { let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba); match v.tex.as_mut() { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => v.tex = Some(ctx.load_texture(format!("media-{id}"), img, egui::TextureOptions::LINEAR)), + Some(t) => crate::textures::replace(ctx, t, img), + None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)), } v.frame = Some(f); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c6f1e640..440fd939 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -70,10 +70,8 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); return true; } - (F, true, false, false) => { - app.palette = Some((String::new(), 0)); - return true; - } + (F, true, false, false) => "app.find", + (H, true, false, false) => "app.replace", _ => return false, }; let _ = app.run(id, json!({})); @@ -193,7 +191,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Duplicate", "edit.duplicate"), ("Delete Slide", "slide.delete"), ("-", ""), - ("Find…", "app.palette"), + ("Find…", "app.find"), + ("Replace…", "app.replace"), ], ), ( @@ -213,6 +212,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), ("Guides", "view.guides"), + ("Grid Options…", "view.gridOptions"), ("-", ""), ("Zoom…", "view.zoomDialog"), ("Zoom In", "view.zoomIn"), @@ -246,6 +246,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ( "Format", vec![ + ("Font…", "format.fontDialog"), + ("Paragraph…", "format.paragraphDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs new file mode 100644 index 00000000..7714637b --- /dev/null +++ b/crates/ui-egui/src/notes.rs @@ -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, +} + +/// 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 { + 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> { + 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 { + let whole = job(body, size, color, wrap); + if body.paragraphs.is_empty() { + return ui.fonts_mut(|f| f.layout_job(whole)); + } + let parts: Vec> = 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, 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::(), 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)); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 6225753a..2c69308c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -29,6 +29,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "comments" => "Comments", "designer" => "Design Ideas", "smartart" => "Text Pane", + "thesaurus" => "Thesaurus", + "revisions" => "Revisions", _ => "Pane", }; ui.horizontal(|ui| { @@ -53,6 +55,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "comments" => comments(app, ui), "designer" => designer(app, ui), "smartart" => smartart(app, ui), + "thesaurus" => thesaurus(app, ui), + "revisions" => revisions(app, ui), _ => {} }); }); @@ -151,6 +155,29 @@ fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } +/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s +/// `transparency` parameter writes as an alpha transform. Anything else — no fill, a +/// gradient, a picture, a pattern — is 0, because that command only changes a solid. +fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 { + let Some(Fill::Solid { color }) = fill else { + return 0.0; + }; + ((1.0 - color.alpha()) * 100.0) as f32 +} + +/// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus +/// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill. +fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option { + if !shown_percent.is_finite() { + return None; + } + let shown = shown_percent.clamp(0.0, 100.0); + if (shown - seed_percent).abs() <= 0.01 { + return None; + } + Some(f64::from(shown) / 100.0) +} + fn scheme(app: &SlideApp) -> deckcraft_color::ColorScheme { app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| deckcraft_model::Theme::default().colors) } @@ -423,9 +450,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Pattern(_)) => "pattern", _ => "auto", }; - for (l, k) in - [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] - { + for (l, k) in [ + ("No fill", "none"), + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Automatic", "auto"), + ] { if ui.radio(kind == k, l).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), @@ -436,6 +468,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), ), "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + "picture" => pick_fill_picture(app), _ => run(app, "shape.fill", json!({"reset": true})), } } @@ -446,14 +479,24 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { ui.label("Transparency"); - let id = ui.id().with("ftr"); - let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0)); + // Seed from this shape. A shared temp defaulted to 0, so a shape that was + // already 40% transparent showed as opaque, and leaving the slider wrote 0. + let seed = solid_fill_transparency_percent(sh.fill.as_ref()); + let id = ui.id().with(("ftr", sh.id.0)); + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed)); let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); - ui.data_mut(|d| d.insert_temp(id, v)); if r.drag_stopped() || r.lost_focus() { - let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); + if let Some(t) = fill_transparency_to_apply(v, seed) { + let _ = app.run("shape.fill", json!({"transparency": t})); + } + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() || r.changed() { + ui.data_mut(|d| d.insert_temp(id, v)); } }); + if let Some(Fill::Picture(pf)) = &sh.fill { + picture_fill_options(app, ui, sh.id.0, pf); + } if let Some(Fill::Gradient(g)) = &sh.fill { let sc = scheme(app); if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { @@ -594,6 +637,95 @@ fn fx_slider( }); } +/// Asks for a picture file and makes it the selected shapes' fill. +fn pick_fill_picture(app: &mut SlideApp) { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } +} + +/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` +/// when it changes. +fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); + } + }); +} + +/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. +fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + ui.horizontal(|ui| { + ui.label("Picture source"); + if ui.button("Insert…").clicked() { + pick_fill_picture(app); + } + }); + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); + } + match &pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); + pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); + pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); + pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); + let aligns = [ + ("tl", "Top left"), + ("t", "Top"), + ("tr", "Top right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom left"), + ("b", "Bottom"), + ("br", "Bottom right"), + ]; + let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; + for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); + egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in opts { + if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + run(app, "shape.fill", json!({"pictureProps": {key: k}})); + } + } + }); + }); + } + } + PictureMode::Stretch { fill_rect } => { + for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = fill_rect[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let mut rect = *fill_rect; + rect[i] = v / 100.0; + run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); + } + }); + } + } + } + let mut rot = !pf.upright; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); + } +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); @@ -652,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)})); @@ -681,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") @@ -689,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); } _ => {} } @@ -699,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})); @@ -712,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 }; @@ -722,13 +942,9 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); ui.horizontal(|ui| { - if ui.button("▶ Play All").clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - s.autoplay = true; - } + let playing = app.anim_preview_active(); + if ui.button(if playing { "■ Stop" } else { "▶ Play All" }).clicked() { + run(app, "animation.preview", json!({"on": !playing})); } }); ui.separator(); @@ -873,6 +1089,61 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = paint_anim_tile; } +/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it. +fn revisions(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("review.revisions", &json!({})) else { + ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + return; + }; + let changes = v["changes"].as_array().cloned().unwrap_or_default(); + ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + if v["byPosition"].as_bool() == Some(true) { + ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + } + ui.add_space(4.0); + if changes.is_empty() { + ui.label("No differences left."); + if ui.button("End Review").clicked() { + run(app, "review.endCompare", json!({})); + app.ui.pane = None; + } + return; + } + if ui.button(format!("Accept All ({})", changes.len())).clicked() { + run(app, "review.accept", json!({"all": true})); + } + ui.separator(); + for (i, c) in changes.iter().enumerate() { + let slide = c["slide"].as_u64().unwrap_or(0); + let (head, col) = match c["kind"].as_str().unwrap_or("") { + "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + }; + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(2), col); + if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { + let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); + run(app, "slide.go", json!({"index": (slide as usize).min(max)})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Accept").clicked() { + run(app, "review.accept", json!({"index": i})); + } + }); + }); + for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { + ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); + } + }); + ui.add_space(4.0); + } +} + fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { @@ -993,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"); @@ -1002,13 +1273,16 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { if cache.key != key || cache.tex.is_empty() { cache = IdeaThumbs { key, tex: vec![] }; let mut render = |name: String, d: &deckcraft_model::Presentation| { - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let (px, h) = crate::textures::fit_size( + (px, (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); let img = deckcraft_render::render_slide( d, idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false)); cache.tex.push((name, tex, px, h)); }; for (id, _) in &ideas { @@ -1029,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, @@ -1088,3 +1365,135 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec #[cfg(test)] #[path = "smartart_tests.rs"] mod smartart_tests; + +#[cfg(test)] +#[path = "background_tests.rs"] +mod background_tests; + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use deckcraft_color::{ColorTransform, SchemeSlot}; + use deckcraft_engine::Session; + use deckcraft_model::{ColorRef, Fill, ShapeId}; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + use serde_json::json; + + use super::{fill_transparency_to_apply, solid_fill_transparency_percent}; + use crate::{Services, SlideApp}; + + #[test] + fn solid_fill_transparency_reads_the_alpha_shape_fill_writes() { + assert_eq!(solid_fill_transparency_percent(None), 0.0); + assert_eq!(solid_fill_transparency_percent(Some(&Fill::None)), 0.0); + assert_eq!(solid_fill_transparency_percent(Some(&Fill::Group)), 0.0); + let plain = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1)); + assert_eq!(solid_fill_transparency_percent(Some(&plain)), 0.0); + // `shape.fill` transparency 0.4 stores Alpha((1 - 0.4) * 100_000). + let forty = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(60_000))); + let p = solid_fill_transparency_percent(Some(&forty)); + assert!((p - 40.0).abs() < 0.01, "{p}"); + let clear = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0))); + assert!((solid_fill_transparency_percent(Some(&clear)) - 100.0).abs() < 0.01); + // A later alpha wins, matching ColorRef::alpha. + let layered = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0)).with(ColorTransform::Alpha(60_000))); + assert!((solid_fill_transparency_percent(Some(&layered)) - 40.0).abs() < 0.01); + + assert_eq!(fill_transparency_to_apply(40.0, 40.0), None); + assert_eq!(fill_transparency_to_apply(40.005, 40.0), None); + assert_eq!(fill_transparency_to_apply(f32::NAN, 40.0), None); + assert_eq!(fill_transparency_to_apply(55.0, 40.0), Some(0.55)); + assert_eq!(fill_transparency_to_apply(0.0, 40.0), Some(0.0)); + } + + fn solid_alpha(app: &SlideApp, id: u32) -> Option { + let sh = app.session.active()?.shape(ShapeId(id))?; + let Fill::Solid { color } = sh.fill.as_ref()? else { return None }; + color.mods.iter().find_map(|m| match m { + ColorTransform::Alpha(v) => Some(*v), + _ => None, + }) + } + + fn slider_values(h: &Harness<'_, SlideApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::Slider).filter_map(|n| n.accesskit_node().numeric_value()).collect() + } + + /// The Fill slider shows the selected solid's transparency, and opening the pane + /// (or looking at another shape first) does not write 0% over it. + #[test] + fn fill_transparency_slider_follows_the_selection() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.fill", &json!({"id": a, "color": "accent1", "transparency": 0.0})).unwrap(); + s.execute("shape.fill", &json!({"id": b, "color": "accent1", "transparency": 0.4})).unwrap(); + s.execute("edit.select", &json!({"ids": [a]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "fill".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let opaque: Vec = slider_values(&h); + assert!(opaque.iter().any(|v| (*v - 0.0).abs() < 0.5), "opaque shape should show 0%, got {opaque:?}"); + assert!(opaque.iter().all(|v| (*v - 40.0).abs() > 0.5), "opaque shape should not show 40%, got {opaque:?}"); + + h.state_mut().session.execute("edit.select", &json!({"ids": [b]})).unwrap(); + h.run_steps(3); + let clear: Vec = slider_values(&h); + assert!(clear.iter().any(|v| (*v - 40.0).abs() < 0.5), "40% fill should show 40%, got {clear:?}"); + assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + + h.get_by_label("Transparency").click(); + h.run_steps(2); + assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + } +} + +/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word +/// when editing text, otherwise looks that synonym up in turn. +fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let mut q = app.thesaurus_query.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + if edit.changed() { + app.thesaurus_query = q.clone(); + } + ui.add_space(6.0); + let senses = deckcraft_engine::thesaurus::lookup(&q); + if q.trim().is_empty() { + ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + return; + } + if senses.is_empty() { + ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + return; + } + let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); + for sense in senses { + ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); + for syn in sense.synonyms { + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + if r.clicked() { + if editing { + run(app, "review.replaceWord", json!({"with": syn})); + } + app.thesaurus_query = syn; + } + } + ui.add_space(6.0); + } +} + +#[cfg(test)] +#[path = "thesaurus_tests.rs"] +mod thesaurus_tests; diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs new file mode 100644 index 00000000..fb8e796b --- /dev/null +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -0,0 +1,87 @@ +//! The Paragraph dialog: Home › Paragraph › Paragraph… opens it, OK applies one undo step, +//! and Tabs… edits stops then returns to Paragraph. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn align_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align.map(|a| a.xml().to_string())) +} + +fn tabs_of(h: &Harness<'_, SlideApp>) -> Vec<(f64, String)> { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.tabs.clone()) + .unwrap_or_default() + .into_iter() + .map(|t| (t.pos, t.align)) + .collect() +} + +#[test] +fn paragraph_button_aligns_text_in_one_undo() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph")); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("OK").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(align_of(&h).as_deref(), Some("r")); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); +} + +#[test] +fn tabs_from_paragraph_applies_a_stop_and_returns() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("Tabs…").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("stops".into(), "2:ctr".into()); + } + h.get_by_label("OK").click_accesskit(); + // The Tabs OK frame restores Paragraph; the next frame fills its fields. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status); + assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right")); + assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…"); + h.get_by_label("Cancel").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); +} diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs new file mode 100644 index 00000000..432a004b --- /dev/null +++ b/crates/ui-egui/src/preview.rs @@ -0,0 +1,38 @@ +//! In-canvas animation preview: the current slide's effects played on the editor canvas +//! (Animations ▸ Preview, Animation Pane ▸ Play All). The slide show uses the same mapping. + +use deckcraft_anim::Timeline; +use deckcraft_geom::Vec2; +use deckcraft_model::ShapeId; +use deckcraft_render::{ParaState, ShapeState}; + +/// How long the finished sequence stays on the canvas before the editor draws the slide at rest. +pub const HOLD_SECS: f64 = 0.45; + +/// Shape and paragraph state when `step` main steps are done and the current step has played `t` seconds. +pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { + let a = tl.state(id, None, step, t); + ShapeState { + visible: a.visible, + opacity: a.opacity, + offset: Vec2::new(a.offset_x, a.offset_y), + scale: (a.scale_x, a.scale_y), + rotate: a.rotate, + clip: a.clip, + tint: a.tint, + paras: tl + .paragraph_targets(id) + .into_iter() + .map(|p| { + let a = tl.state(id, Some(p), step, t); + (p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) }) + }) + .collect(), + } +} + +/// State on the play-everything clock (preview and Play All). +pub fn shape_state_at(tl: &Timeline, id: ShapeId, time: f64) -> ShapeState { + let (step, t) = tl.locate(time); + shape_state(tl, id, step, t) +} diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index cec694bf..205c65aa 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -50,7 +50,8 @@ fn ribbon_button_opens_the_dialog_and_replaces() { d.fields.insert("to".into(), "Lato".into()); } h.run_steps(2); - h.get_by_label("Replace").click(); + // The ribbon has a Replace button too (Find and Replace); the dialog's is drawn last. + h.query_all_by_label("Replace").last().expect("the dialog's Replace button").click(); h.run_steps(3); assert_eq!(run_font(&h).as_deref(), Some("Lato")); // The dialog stays open with a confirmation, like PowerPoint's. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d2c87aea..b33b9dc4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -395,9 +395,9 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - 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( @@ -564,6 +564,9 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } }); }); }); @@ -743,7 +746,10 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } if ui.button("Line Spacing Options…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + run(app, "format.paragraphDialog", json!({})); + } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); @@ -786,6 +792,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); + if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + run(app, "format.paragraphDialog", json!({})); + } }); }); }); @@ -1151,12 +1160,17 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } + }); // Also under Arrange, but that is disabled until something is selected (#132). big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); @@ -1420,8 +1434,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { } }); group(ui, |ui| { - let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false); - let _ = r.on_hover_text("Ink to Shape (coming soon)"); + let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); + if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + app.session.set_tool(ToolKind::Select); + run(app, "draw.inkToShape", json!({})); + } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); let _ = r.on_hover_text("Ink to Text (coming soon)"); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); @@ -1646,12 +1664,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { - let opts: &[&str] = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let selected = cur.as_ref().map(|t| t.option.as_str()); + let rows = transition_effect_rows(&kind, selected); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + for &(o, on) in &rows { + if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { run(app, "transition.options", json!({"option": o})); + ui.close(); } } }); @@ -1832,13 +1852,10 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { s.animations.iter().find(|a| a.shape == *id).cloned() }); group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - s.autoplay = true; - } + let playing = app.anim_preview_active(); + let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { + run(app, "animation.preview", json!({})); } }); group(ui, |ui| { @@ -1900,18 +1917,23 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); } }); - let opts: &[&str] = cur.as_ref().and_then(|c| deckcraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let build = cur.as_ref().map(|c| text_build_id(c.text_build)); + let build_rows = marked_options(&["asOne", "byParagraph"], build); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(option_label(o)).clicked() { + for &(o, on) in &effect_rows { + if ui.selectable_label(on, option_label(o)).clicked() { run(app, "animation.options", json!({"option": o})); + ui.close(); } } ui.separator(); - for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] { - if ui.button(l).clicked() { - run(app, "animation.options", json!({"textBuild": b})); + for &(id, on) in &build_rows { + let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; + if ui.selectable_label(on, label).clicked() { + run(app, "animation.options", json!({"textBuild": id})); + ui.close(); } } }); @@ -2108,8 +2130,7 @@ fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { fn review(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); - let _ = r.on_hover_text("Thesaurus (coming soon)"); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); }); group(ui, |ui| { big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); @@ -2119,6 +2140,19 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { let _ = r.on_hover_text("Translation needs a language model (not bundled)"); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); group(ui, |ui| { big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); rows(ui, |ui| { @@ -2178,6 +2212,9 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { app.ui.guides = gd; } }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } let n = app.ui.notes; if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { app.ui.notes = !n; @@ -2349,6 +2386,8 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.editPoints", json!({"on": true})); ui.close(); } + ui.separator(); + smooth_corners_row(app, ui); }); let can_merge = enabled(app, "shape.merge"); let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); @@ -2600,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!({})); }); }); @@ -3289,6 +3328,32 @@ fn handout_master(app: &mut SlideApp, ui: &mut Ui) { }); } +/// The stored id is the one marked row. Every other id stays unmarked. +fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { + options.iter().copied().map(|o| (o, current == Some(o))).collect() +} + +/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. +fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + marked_options(opts, current) +} + +/// One row of Animations ▸ Effect Options for the selected effect. +fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); + marked_options(opts, current) +} + +fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { + match build { + deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", + deckcraft_model::anim::TextBuild::ByWord => "byWord", + deckcraft_model::anim::TextBuild::ByLetter => "byLetter", + deckcraft_model::anim::TextBuild::AsOne => "asOne", + } +} + /// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); @@ -3315,6 +3380,7 @@ fn chart_menu_types() -> Vec> { #[cfg(test)] mod tests { use super::*; + use deckcraft_model::anim::TextBuild; /// Paints one transition tile and one animation tile and returns the colour of every label. fn tile_label_colours(dark: bool) -> Vec { @@ -3344,6 +3410,26 @@ mod tests { let light = tile_label_colours(false); assert_eq!(light, vec![Tokens::light().text; 2]); } + + fn marked(rows: Vec<(&str, bool)>) -> Vec<&str> { + rows.into_iter().filter(|(_, on)| *on).map(|(id, _)| id).collect() + } + + #[test] + fn effect_options_mark_only_the_stored_choice() { + assert_eq!(marked(transition_effect_rows("push", Some("l"))), vec!["l"]); + assert_eq!(transition_effect_rows("push", Some("l")).into_iter().map(|(_, on)| on).collect::>(), vec![false, false, true, false]); + assert_eq!(marked(transition_effect_rows("fade", Some("throughBlack"))), vec!["throughBlack"]); + assert!(marked(transition_effect_rows("push", Some("missing"))).is_empty()); + assert!(transition_effect_rows("none", None).is_empty()); + assert_eq!(marked(transition_effect_rows("cut", Some(""))), vec![""]); + assert_eq!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("l"))), vec!["l"]); + assert!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("nope"))).is_empty()); + assert!(animation_effect_rows("appear", AnimClass::Entrance, Some("l")).is_empty()); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByParagraph)))), vec!["byParagraph"]); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::AsOne)))), vec!["asOne"]); + assert!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByWord)))).is_empty()); + } } #[cfg(test)] @@ -3376,3 +3462,49 @@ mod title_bar_tests { #[cfg(test)] #[path = "drawtable_tests.rs"] mod drawtable_tests; + +#[cfg(test)] +#[path = "inktoshape_tests.rs"] +mod inktoshape_tests; + +/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a +/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui +/// memory, per shape) so later moves re-smooth the same corners. +fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { + let Some((id, radius)) = app.session.active().and_then(|d| { + let id = *d.selection.shapes.first()?; + let sh = d.shape(id)?; + let x = sh.xfrm?; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { + Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) + } + deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + Some((id, own)) + }) else { + return; + }; + let key = egui::Id::new(("deckcraft-smooth-corners", id.0)); + let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); + if let Some(r) = radius { + state.0 = r; + } + let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); + ui.horizontal(|ui| { + ui.label("Smooth Corners"); + let mut pct = state.1; + let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()); + let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; + if resp.changed() { + state.1 = pct; + ui.data_mut(|d| d.insert_temp(key, state)); + } + // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). + if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); + } + }); +} diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs new file mode 100644 index 00000000..7c313563 --- /dev/null +++ b/crates/ui-egui/src/ruler_tests.rs @@ -0,0 +1,160 @@ +//! The ruler's indent and tab markers driven with the mouse while editing text. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=8 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, SlideApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value { + h.state_mut().session.execute("format.ruler", &json!({})).unwrap() +} + +#[test] +fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Name\tRole\tTeam"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + // The horizontal ruler runs along the top of the canvas. + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + + // First-line indent triangle (top) to 36 pt. + drag(&mut h, Pos2::new(rx(0.0), top + 3.0), Pos2::new(rx(36.0), top + 3.0)); + let r = ruler(&mut h); + assert!((r["indent"].as_f64().unwrap() - 36.0).abs() <= 1.0, "{r}"); + assert_eq!(r["marginLeft"].as_f64(), Some(0.0)); + + // Hanging triangle (middle) to 18 pt: the first line stays at 36. + drag(&mut h, Pos2::new(rx(0.0), top + 8.0), Pos2::new(rx(18.0), top + 8.0)); + let r = ruler(&mut h); + let (ml, ind) = (r["marginLeft"].as_f64().unwrap(), r["indent"].as_f64().unwrap()); + assert!((ml - 18.0).abs() <= 1.0 && (ml + ind - 36.0).abs() <= 1.0, "{r}"); + + // Click on the ruler: a left tab; drag it along; drag it off to remove it. + click(&mut h, Pos2::new(rx(150.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + let pos = r["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 150.0).abs() <= 1.0); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(220.0), top + 12.0)); + let pos = ruler(&mut h)["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 220.0).abs() <= 1.0, "{pos}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler.png"); + } + assert!(h.state().session.active().unwrap().selection.text.is_some(), "still editing the text"); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); + assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); +} + +fn editing_harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Item\t9.50"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + let corner = h.state().canvas_rect.unwrap().min + egui::vec2(8.0, 8.0); + assert!(h.query_by_label("Left Tab").is_some()); + for _ in 0..3 { + click(&mut h, corner); + } + assert!(h.query_by_label("Decimal Tab").is_some()); + click(&mut h, Pos2::new(rx(200.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"][0]["align"], "dec", "{r}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler_decimal.png"); + } +} + +#[test] +fn tabs_dialog_sets_clears_and_applies_on_ok() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("tabs")); + h.run_steps(3); + assert!(h.query_by_label_contains("0.50\"").is_some(), "the existing stop is listed"); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("newpos".into(), "2".into()); + d.fields.insert("default".into(), "0.5".into()); + h.run_steps(1); + h.get_by_label("Center").click(); + h.run_steps(2); + h.get_by_label("Set").click(); + h.run_steps(2); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/tabs_dialog.png"); + } + // Nothing changes until OK. + assert_eq!(ruler(&mut h)["tabs"].as_array().unwrap().len(), 1); + h.query_all_by_label("Clear").next().expect("a Clear button per stop").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!((r["tabs"][0]["pos"].as_f64(), r["tabs"][0]["align"].as_str()), (Some(144.0), Some("ctr"))); + assert_eq!(r["defaultTab"].as_f64(), Some(36.0)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ruler(&mut h)["tabs"][0]["pos"].as_f64(), Some(36.0), "one undo step"); +} diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs new file mode 100644 index 00000000..9a1b8b62 --- /dev/null +++ b/crates/ui-egui/src/settings.rs @@ -0,0 +1,170 @@ +//! The desktop app's settings files, `ui.json` ([`UiState`]) and `prefs.json` +//! ([`deckcraft_engine::Prefs`]), in the per-user settings folder. They are files on disk that a +//! crash, a full disk or a hand edit can leave damaged, so reading never fails and never trusts +//! them: an unreadable, oversized or malformed file gives the defaults, and the values of a +//! well-formed one are brought into range ([`UiState::sanitized`], `Prefs::sanitized`). Writing +//! goes through a temporary file and a rename, so a crash mid-write leaves the old file. + +use std::path::Path; + +use deckcraft_engine::Prefs; + +use crate::UiState; + +pub const UI_FILE: &str = "ui.json"; +pub const PREFS_FILE: &str = "prefs.json"; +/// Larger settings files are not settings. +pub const MAX_BYTES: u64 = 4 << 20; + +/// The JSON in `path`, when it is a regular file of a sane size that holds a `T`. +fn read(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_BYTES { + log::warn!("{}: not a settings file; using the defaults", path.display()); + return None; + } + let bytes = std::fs::read(path).ok()?; + match serde_json::from_slice(&bytes) { + Ok(v) => Some(v), + Err(e) => { + log::warn!("{}: {e}; using the defaults", path.display()); + None + } + } +} + +/// The saved UI state and preferences in `dir` (each `None` when missing or unusable). +pub fn load(dir: &Path) -> (Option, Option) { + (read::(&dir.join(UI_FILE)).map(UiState::sanitized), read::(&dir.join(PREFS_FILE)).map(Prefs::sanitized)) +} + +/// Write `bytes` to `path` through a temporary file beside it. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +/// Save both files into `dir` (created when missing). Failures are logged, not fatal. +pub fn save(dir: &Path, ui: &UiState, prefs: &Prefs) { + if let Err(e) = std::fs::create_dir_all(dir) { + log::warn!("{}: {e}; settings not saved", dir.display()); + return; + } + for (name, json) in [(UI_FILE, serde_json::to_vec_pretty(ui)), (PREFS_FILE, serde_json::to_vec_pretty(prefs))] { + let path = dir.join(name); + if let Err(e) = json.map_err(std::io::Error::other).and_then(|b| write_atomic(&path, &b)) { + log::warn!("{}: {e}", path.display()); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("deckcraft-settings-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn damaged_settings_files_give_the_defaults() { + let d = dir("damaged"); + let cases: [&[u8]; 6] = [b"", b"{", b"\xFF\xFE garbage", b"[1, 2, 3]", b"{\"zoom\": \"big\"}", b"null"]; + for bytes in cases { + std::fs::write(d.join(UI_FILE), bytes).unwrap(); + std::fs::write(d.join(PREFS_FILE), bytes).unwrap(); + let (ui, prefs) = load(&d); + // Prefs has no `zoom`, so that case is a valid (default) Prefs. + assert!(ui.is_none(), "{:?}", String::from_utf8_lossy(bytes)); + assert!(prefs.is_none() || bytes.starts_with(b"{\"zoom"), "{:?}", String::from_utf8_lossy(bytes)); + } + // A folder where a file should be, and an oversized file. + std::fs::remove_file(d.join(UI_FILE)).unwrap(); + std::fs::create_dir(d.join(UI_FILE)).unwrap(); + std::fs::write(d.join(PREFS_FILE), vec![b' '; MAX_BYTES as usize + 1]).unwrap(); + assert!(matches!(load(&d), (None, None))); + // Nothing there at all. + assert!(matches!(load(&d.join("missing")), (None, None))); + } + + #[test] + fn out_of_range_values_are_brought_into_range() { + let d = dir("ranges"); + let recent: Vec = (0..5000).map(|i| format!("/tmp/deck{i}.pptx")).collect(); + let ui = serde_json::json!({ + "zoom": 1.0e30, "sorterZoom": -4.0, "uiScale": 1.0e9, "notesHeight": 1.0e30, + "thumbsWidth": -1.0e30, "paneWidth": 3.4e38, "tab": "x".repeat(10_000), "pane": "y".repeat(500), + "recent": recent, + }); + let prefs = serde_json::json!({"gridSpacing": 0.0, "duplicateOffset": 1.0e300, "undoLimit": u64::MAX, "recoveryMinutes": -5.0, "author": "a".repeat(100_000)}); + std::fs::write(d.join(UI_FILE), ui.to_string()).unwrap(); + std::fs::write(d.join(PREFS_FILE), prefs.to_string()).unwrap(); + let (ui, prefs) = load(&d); + let (ui, prefs) = (ui.unwrap(), prefs.unwrap()); + assert_eq!(ui.zoom, Some(*crate::ZOOM_RANGE.end())); + assert_eq!((ui.sorter_zoom, ui.ui_scale), (0.5, 3.0)); + assert!(ui.notes_height <= 2000.0 && ui.thumbs_width >= 60.0 && ui.pane_width <= 520.0); + assert_eq!((ui.tab.as_str(), ui.pane.as_deref()), ("Home", None)); + assert_eq!(ui.recent.len(), crate::RECENT_MAX); + assert_eq!(prefs.grid_spacing, Prefs::default().grid_spacing); + assert_eq!(prefs.duplicate_offset, 1000.0); + assert_eq!(prefs.undo_limit, deckcraft_engine::MAX_UNDO_LIMIT); + assert_eq!(prefs.recovery_minutes, 0.1); + assert_eq!(prefs.author.chars().count(), 256); + // A zero or negative zoom means "fit". + std::fs::write(d.join(UI_FILE), r#"{"zoom": 0}"#).unwrap(); + assert_eq!(load(&d).0.unwrap().zoom, None); + } + + #[test] + fn saving_round_trips_and_leaves_no_temporary_file() { + let d = dir("save"); + let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() }; + let prefs = Prefs { author: "Ann".into(), ..Default::default() }; + save(&d.join("nested"), &ui, &prefs); + let (u, p) = load(&d.join("nested")); + assert_eq!(u.unwrap().zoom, Some(1.5)); + assert_eq!(p.unwrap().author, "Ann"); + let names: Vec = + std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}"); + // A settings folder that is a file: nothing saved, no panic. + std::fs::write(d.join("blocked"), "file").unwrap(); + save(&d.join("blocked"), &ui, &prefs); + } + + /// The window opens with the worst `ui.json` values once they are sanitized; a zoom of + /// 10,000 % used to panic in egui and 1e30 exhausted memory at start-up. + #[test] + fn the_window_opens_with_hostile_saved_state() { + let hostile = UiState { + zoom: Some(1.0e30), + notes_height: 1.0e30, + thumbs_width: -1.0e30, + pane: Some("format".into()), + pane_width: 1.0e30, + ..Default::default() + }; + for ui in [hostile.clone().sanitized(), UiState { zoom: Some(1.0e4), ..Default::default() }] { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default()); + // The second case is not sanitized: the canvas keeps a stray zoom in range itself. + app.ui = ui; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut crate::SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas_scale <= *crate::ZOOM_RANGE.end() + 1e-3, "{}", h.state().canvas_scale); + } + } +} diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 13e23492..914a5a55 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -53,6 +53,8 @@ pub struct Show { notes_px: f32, timings: Vec<(usize, f64)>, last_move: f64, + /// The last trackpad flick or mouse-wheel notch, so one gesture turns one page. + last_swipe: f64, pub ended: bool, /// The slide whose media was last started (auto-play runs once per visit). media_slide: Option, @@ -199,6 +201,7 @@ impl Show { notes_px: NOTES_DEFAULT, timings: vec![], last_move: 0.0, + last_swipe: f64::NEG_INFINITY, ended: false, media_slide: None, last_viewed: None, @@ -374,6 +377,14 @@ fn fit(avail: Rect, doc: &Presentation) -> Rect { /// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective /// cards subdivided so their pictures stay straight) with per-vertex opacity. +/// Where presenter view's "Next" preview goes: the column's width below its label, kept inside +/// the top 60% of the column. A portrait slide used to get the full width and a height many +/// times the window (a white slab over the notes and past the window edge). +fn next_rect(right: Rect, doc: &Presentation) -> Rect { + let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6)); + Rect::from_min_size(area.min, fit(area, doc).size()) +} + fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { let mut m = match tex { Some(id) => Mesh::with_texture(id), @@ -399,7 +410,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; let tex = |i: usize, part: SlidePart, name: &str| { let img = deckcraft_render::render_slide_part(doc, i, part, &opts); - ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR) + crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)) }; [ from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), @@ -605,6 +616,24 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm { actions.push(show.state.prev(&doc)); } + // A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick + // there stays on the slide. Pinch zoom is not a show gesture. + if !show.pen + && let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta)) + && now - show.last_swipe > 0.35 + { + show.last_swipe = now; + if next { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } else { + show.ended = false; + actions.push(show.state.prev(&doc)); + } + } // Apply input first: a step started by this click must restart the clock before it is read. for a in actions.drain(..) { if !show.apply(a, &doc, now) { @@ -649,7 +678,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let painter = ui.painter_at(full); painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); let ppp = ctx.pixels_per_point(); - let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32); + // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit + // (drawn stretched to the screen). + let size = crate::textures::fit_size( + ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), + crate::textures::max_side(&ctx), + ); if show.ended { painter.text( pos2(full.center().x, full.min.y + 40.0), @@ -697,10 +731,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { }; let tex = match show.tex.take() { Some((_, mut h)) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "show", ci), }; show.tex = Some((key, tex.clone())); Some(tex) @@ -753,10 +787,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ci = crate::textures::to_color_image(&img, false); let frame = match tr.morph.take() { Some(mut h) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "morph", ci), }; painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); tr.morph = Some(frame); @@ -1684,7 +1718,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi i, &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, ); - let tex = ctx.load_texture(format!("show-grid-{i}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); show.thumbs.push((i, tex.clone())); Some(tex) } @@ -1696,16 +1730,17 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if let Some(n) = next && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) { + // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. + let next_size = crate::textures::fit_size( + (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_slide( doc, n, - &RenderOpts { - scale: 480.0 / doc.slide_size.width.max(1.0), - size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), - ..Default::default() - }, + &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, ); - show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))); + show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); } let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default(); @@ -1797,10 +1832,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if let Some(c) = show.blank { ui.painter().rect_filled(cr, CornerRadius::ZERO, c); } - let nr = Rect::from_min_size( - right.min + vec2(0.0, 22.0), - vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32), - ); + let nr = next_rect(right, doc); ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); match &next_tex { Some(t) => { @@ -1963,7 +1995,7 @@ mod tests { s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap(); let doc = s.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR); + let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); let mut show = Show::new(&doc, 0, false, false); show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone())); assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); @@ -2197,4 +2229,21 @@ mod tests { show.push_slide_number('a', 1.0); assert!(show.goto.chars().all(|c| c.is_ascii_digit())); } + + #[test] + fn the_next_preview_fits_its_column_whatever_the_slide_shape() { + let right = Rect::from_min_size(pos2(700.0, 100.0), vec2(400.0, 600.0)); + let mut s = deckcraft_engine::Session::with_new(); + for (w, h) in [(72, 4032), (4032, 72), (960, 540)] { + s.execute("design.slideSize", &json!({"w": w, "h": h})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let r = next_rect(right, &doc); + assert!(right.contains_rect(r), "{w}x{h}: {r:?}"); + let aspect = r.height() / r.width(); + assert!((aspect - h as f32 / w as f32).abs() / (h as f32 / w as f32) < 0.01, "{w}x{h}: {r:?}"); + } + // 16:9 still takes the column's full width. + let doc = s.active().unwrap().doc.clone(); + assert_eq!(next_rect(right, &doc).width(), 400.0); + } } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 1300ff48..4c97605c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -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!({})); diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 05196fc0..d5d03cdf 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -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() @@ -45,7 +41,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() { app.ui.zoom = None; } - let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round(); + let pct = (app.canvas_scale * 100.0).round(); let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); if lbl.clicked() { let _ = app.run("view.zoomDialog", json!({})); @@ -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 { 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; }); } diff --git a/crates/ui-egui/src/texture_limit_tests.rs b/crates/ui-egui/src/texture_limit_tests.rs new file mode 100644 index 00000000..69dbf523 --- /dev/null +++ b/crates/ui-egui/src/texture_limit_tests.rs @@ -0,0 +1,69 @@ +//! Textures stay within the renderer's `max_texture_side` (2048 in the test harness, as on many +//! GPUs and software fallbacks): bigger ones panicked in egui. Slide show on a big screen, +//! thumbnails, master views and presenter view of a very tall slide. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn harness(session: Session, size: egui::Vec2) -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ) +} + +/// The tallest slide allowed (1 × 56): its thumbnails and presenter-view preview are tens of +/// thousands of pixels high at their usual widths. +fn tall_deck() -> Session { + let mut s = Session::with_new(); + s.execute("design.slideSize", &json!({"w": 72, "h": 4032})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s +} + +#[test] +fn a_slide_show_on_a_screen_wider_than_the_texture_limit() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let mut h = harness(s, egui::vec2(2600.0, 1500.0)); + h.run_steps(2); + h.state_mut().run("show.start", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} + +#[test] +fn a_very_tall_slide_in_the_editor_views_and_presenter_view() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + for (cmd, p) in [("view.sorter", json!({})), ("view.normal", json!({})), ("view.slideMaster", json!({})), ("view.closeMaster", json!({}))] { + let _ = h.state_mut().run(cmd, p); + h.run_steps(3); + } + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} + +/// Presenter view of a portrait slide: the "Next" preview keeps the slide's shape inside its +/// column. It took the column's full width and 56 times that in height, a white slab over the +/// notes and past the window. +#[test] +fn presenter_view_keeps_a_portrait_next_slide_in_its_column() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + let img = h.render().unwrap(); + // Right of the Next column's narrow preview, below where a landscape one would end. + let px = img.get_pixel(1000, 600).0; + assert!(px[0] < 128 && px[1] < 128 && px[2] < 128, "the Next preview spills over the column: {px:?}"); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index a17777b4..9730f4d6 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -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 { @@ -90,7 +90,7 @@ impl Textures { let id = g.id; s.shapes = vec![g]; let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?; - let tex = ctx.load_texture(format!("smartart-{key}"), to_color_image(&img, false), TextureOptions::LINEAR); + let tex = upload(ctx, format!("smartart-{key}"), to_color_image(&img, false)); if self.smartart.len() > 300 { self.smartart.clear(); } @@ -99,6 +99,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; @@ -116,6 +149,77 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { ColorImage::new([w, h], px) } +/// The largest texture side the renderer (GPU or software fallback) takes; egui panics on +/// a bigger texture. +pub fn max_side(ctx: &egui::Context) -> u32 { + ctx.input(|i| i.max_texture_side).clamp(1, u32::MAX as usize) as u32 +} + +/// `(w, h)` scaled down, keeping its shape, so neither side exceeds `max` (and none is 0). +/// Render sizes go through this so a huge display, DPI or slide shape doesn't render a +/// picture the GPU can't hold. +pub fn fit_size((w, h): (u32, u32), max: u32) -> (u32, u32) { + let (w, h, max) = (w.max(1), h.max(1), max.max(1)); + let big = w.max(h); + if big <= max { + return (w, h); + } + let k = max as f64 / big as f64; + (((w as f64 * k).floor() as u32).clamp(1, max), ((h as f64 * k).floor() as u32).clamp(1, max)) +} + +/// `img` shrunk by whole-pixel box averaging until both sides fit `max`. +pub fn shrink_to(img: ColorImage, max: u32) -> ColorImage { + let [w, h] = img.size; + let max = max.max(1) as usize; + if w <= max && h <= max { + return img; + } + let f = w.max(h).div_ceil(max); + let (nw, nh) = (w.div_ceil(f).max(1), h.div_ceil(f).max(1)); + let mut px = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let mut acc = [0u32; 4]; + let mut n = 0u32; + for sy in y * f..((y + 1) * f).min(h) { + for sx in x * f..((x + 1) * f).min(w) { + if let Some(c) = img.pixels.get(sy * w + sx) { + for (a, v) in acc.iter_mut().zip(c.to_array()) { + *a += v as u32; + } + n += 1; + } + } + } + let n = n.max(1); + px.push(egui::Color32::from_rgba_premultiplied((acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8, (acc[3] / n) as u8)); + } + } + ColorImage::new([nw, nh], px) +} + +/// Upload a texture, shrunk to the renderer's limit if need be. +pub fn upload(ctx: &egui::Context, name: impl Into, img: ColorImage) -> TextureHandle { + ctx.load_texture(name, shrink_to(img, max_side(ctx)), TextureOptions::LINEAR) +} + +/// Replace a texture's picture, shrunk to the renderer's limit if need be. +pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) { + tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR); +} + +/// [`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, 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> { p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() } @@ -128,6 +232,14 @@ fn same(a: &Option>, b: &Option>) -> bool { } } +/// Mix the preview playhead into the canvas cache key. Zero means "no preview", so a playhead of +/// 0s still misses the at-rest texture (entrances start hidden). +fn anim_key(anim: Option<(&deckcraft_anim::Timeline, f64)>) -> u64 { + let Some((_, t)) = anim else { return 0 }; + let ms = if t.is_finite() { (t * 1000.0).clamp(0.0, 1.0e12) as u64 } else { 0 }; + ms.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15) +} + fn threads() -> u16 { if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } } @@ -151,6 +263,7 @@ impl Textures { grayscale: bool, gif_times: &[(ShapeId, f64)], skip_text: Option, + anim: Option<(&deckcraft_anim::Timeline, f64)>, ) -> Option<(TextureHandle, Region)> { let slide = p.slides.get(index)?.clone(); let master = master_of(p, &slide); @@ -167,13 +280,15 @@ impl Textures { Some((i, d)) if i == index => estimate.union(d), _ => estimate, }; - let want = deckcraft_render::pasteboard_region(size, scale, reach, visible); + // The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui. + let max_side = max_side(ctx); + let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side); let key = Key { slide: Arc::as_ptr(&slide) as usize, master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), size, edit, - extra: extra ^ grayscale as u64, + extra: extra ^ grayscale as u64 ^ anim_key(anim), want, skip_text, }; @@ -184,14 +299,17 @@ impl Textures { } let t0 = crate::now_ms(); let render = |region: Region| { + let state_of = |id: ShapeId| anim.map(|(tl, time)| crate::preview::shape_state_at(tl, id, time)).unwrap_or_default(); + let state: Option<&dyn Fn(ShapeId) -> deckcraft_render::ShapeState> = if anim.is_some() { Some(&state_of) } else { None }; let opts = RenderOpts { scale, edit, gif_times, - skip_text, threads: threads(), offset: region.offset(), size: Some(region.size()), + state, + skip_text, ..Default::default() }; deckcraft_render::render_slide_drawn(p, index, &opts) @@ -202,7 +320,8 @@ impl Textures { // Text spilling out of its box can reach further than the boxes said. Once drawn, the // region it calls for is known (and is next frame's key): render again only if it shows // more than this render covered. - let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key }; + let key = + Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key }; if !region.contains(&key.want) { region = key.want; img = render(region).0; @@ -213,10 +332,10 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - e.tex.set(ci, TextureOptions::LINEAR); + replace_editor(ctx, &mut e.tex, ci); e.tex } - None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + 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)) @@ -263,9 +382,10 @@ impl Textures { return self.thumbs.get(&key.slide).map(|e| e.tex.clone()); } *budget -= 1; - let scale = width_px as f64 / p.slide_size.width.max(1.0); - let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); - let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + let (tw, th) = fit_size((width_px, h), max_side(ctx)); + let scale = tw as f64 / p.slide_size.width.max(1.0); + let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() }); + let tex = upload_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 }); @@ -279,3 +399,88 @@ impl Textures { Some(tex) } } + +#[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) { + if *len > max { + let hi = *start as i64 + (*len - max) as i64; + *start = (vis as i64).clamp(*start as i64, hi) as i32; + *len = max; + } + } + axis(&mut r.x, &mut r.w, visible.x, max); + axis(&mut r.y, &mut r.h, visible.y, max); + r +} + +#[cfg(test)] +mod fit_tests { + use super::*; + + #[test] + fn canvas_textures_stay_within_the_texture_limit() { + let r = Region { x: -100, y: -50, w: 5000, h: 1000 }; + let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000)); + // Visible past the far end: the cut is pinned there. + let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048)); + assert_eq!(fit_texture(r, r, 8192), r); + } + + #[test] + fn render_sizes_and_pictures_shrink_to_the_limit_keeping_their_shape() { + assert_eq!(fit_size((195, 10920), 2048), (36, 2048)); + assert_eq!(fit_size((3000, 1500), 2048), (2048, 1024)); + assert_eq!(fit_size((800, 600), 2048), (800, 600)); + assert_eq!(fit_size((0, 0), 0), (1, 1)); + // A 5000 × 3 picture: half red, half blue, averaged down to 1667 × 1. + let img = ColorImage::new( + [5000, 3], + (0..3) + .flat_map(|y| (0..5000).map(move |x| (x, y))) + .map(|(x, _)| if x < 2500 { egui::Color32::RED } else { egui::Color32::BLUE }) + .collect(), + ); + let small = shrink_to(img, 2048); + assert_eq!(small.size, [1667, 1]); + assert_eq!(small.pixels[0], egui::Color32::RED); + assert_eq!(small.pixels[1666], egui::Color32::BLUE); + let ok = ColorImage::filled([10, 10], egui::Color32::WHITE); + assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size); + } +} diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/thesaurus_tests.rs new file mode 100644 index 00000000..985e6401 --- /dev/null +++ b/crates/ui-egui/src/thesaurus_tests.rs @@ -0,0 +1,54 @@ +//! Review › Thesaurus driven like a person: caret in a word, open the pane, click a synonym. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn thesaurus_pane_swaps_the_word_at_the_caret() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Review".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Thesaurus").click(); + h.run_steps(3); + assert_eq!(h.state().ui.pane.as_deref(), Some("thesaurus")); + assert_eq!(h.state().thesaurus_query, "Goal"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/thesaurus.png"); + } + h.get_by_label("Objective").click(); + h.run_steps(3); + let text = h + .state() + .session + .active() + .unwrap() + .shapes() + .iter() + .find(|x| x.id.0 as u64 == id) + .and_then(|x| x.text.as_ref()) + .map(|t| t.paragraphs[0].text()); + assert_eq!(text.as_deref(), Some("Our Objective is growth.")); + assert_eq!(h.state().thesaurus_query, "Objective", "the pane follows the new word"); + + // Typing a word that isn't listed says so instead of showing nothing. + h.state_mut().thesaurus_query = "zzxq".into(); + h.run_steps(2); + assert!(h.query_by_label_contains("No synonyms").is_some()); +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 1d84378f..d6c9c02c 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -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,22 +276,25 @@ 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 px = ((tw * ppp) as u32).max(1); + 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( + (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( &doc, mi, li, &deckcraft_render::RenderOpts { - scale: px as f64 / doc.slide_size.width.max(1.0), + scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, - size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), + size: Some(px), ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload_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, diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 887a9704..c8adf49b 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (animation bounce end and repeat round-trip; rewind still #77) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Animations ▸ Preview plays the current slide on the editor canvas; previous: major, transitions measured frame by frame) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -74,6 +75,9 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Animation Pane: list, reorder, remove | yes | yes | | Animation Pane: advanced timeline (bars you drag) | yes | yes: bars from the show schedule, drag the body for delay, the right edge for duration, seconds ruler (`animpane_tests`) | | Preview in the editor (current effect, from selected) | yes | partly | +| Animation Pane: advanced timeline (bars you drag) | yes | **no** | +| Preview on the editor canvas (the current slide) | yes | yes | +| Preview from the selected effect | yes | no | | Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) | | Multiple animations on one object | yes | yes | | Round trip of the timing tree | yes | flattened to the pane list on read, rebuilt on write (`every_animation_effect_survives`) | @@ -83,6 +87,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Work | Hours | |---|---| | Preview from selected, effect dialog depth | 4–6 | +| Animation Pane timeline, preview from the selected effect, effect dialog depth | 4–7 | | The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 | | Rewind and other effect options on round trip (#77) | 1–2 | | Transition sounds: the rest of PowerPoint's built-in list (Applause, Arrow, Bomb…) | 1 | @@ -97,6 +102,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) | +| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open | | 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | | 2026-10-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked | | 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) | diff --git a/docs/architecture.md b/docs/architecture.md index bbe1e629..cbe0835e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,8 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; 28 dialogs, 226 commands; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry 227; dialogs stay 27; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry counted at 236; dialogs stay 27; the test-count line stays 504; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog, 227 commands, 28 dialogs; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph and Tabs dialogs, 229 commands, 29 dialogs; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -40,6 +42,10 @@ nothing below L6 uses egui, eframe, winit or rfd. | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (229 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 29 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with @@ -73,7 +79,8 @@ effects project cards through a perspective camera and `anim::layer_mesh` subdiv textures stay straight; soft edges are per-corner opacities. The show renders the slides once per transition and only composites cached textures each frame; `render::composite_transition` is the CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape -comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and +comes from `anim::timeline` each frame. Animations ▸ Preview plays that same state on the editor +canvas for the current slide, then draws the slide at rest. PDF export renders each slide at print resolution and overlays an invisible real-text layer with embedded font subsets. ## File I/O @@ -105,5 +112,8 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | 2026-10-14 | minor | Insert › Date and Time dialog (`insert.dateTime` with no keys). 28 dialogs; command count stays 226 | | 2026-10-14 | minor | Command registry is 227 (`design.effects`). Dialogs stay 27. The test-count line stays 504 | | 2026-10-14 | minor | Command registry counted at 236, including `master.preserve` and `master.placeholders`. Dialogs stay 27. The test-count line stays 504. The menu measurement and the d556ff9 snapshot still count 226 | +| 2026-10-13 | trivial | Animation preview draws the current slide's timeline on the editor canvas | +| 2026-10-14 | minor | Paragraph and Tabs dialogs; command registry 229; 29 dialogs | +| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b14d92ee..b7b6f013 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,13 +17,18 @@ 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) | +| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | | `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show: digits then Enter go to that slide, G is See All Slides, Z arms zoom, T pauses the presenter timer, R resets it, `[` and `]` change notes size, Esc clears a slide number, the grid and zoom before it leaves the show) | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show; Escape stops the canvas animation preview) | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?, pressure?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse. `pressure` is `0..=1` for a pen; omit it for a mouse | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | | `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) | @@ -32,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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5d7bf979..597058c9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index b109aac0..843154ac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -25,6 +25,15 @@ > **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) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; 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 (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt, pinch and trackpad gestures remain; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -53,6 +62,7 @@ revision history. | 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) | | 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) | | 10 | Animation Pane: thin preview (timeline done); 64 of ~190 effects | Feature | B (pane) | 8–14 | [animation-parity.md](animation-parity.md) | +| 10 | Animation Pane: no timeline; preview is the whole slide, not from the selected effect; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) | | 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) | | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -190,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. @@ -247,7 +259,10 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 6–10 h. ### Animation Pane, effect count and preview -Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. +Animations ▸ Preview and the pane's Play All play the current slide's animations on the editor +canvas (Escape or a click stops them). Still open: the advanced timeline, starting from the +selected effect, and most of the effect gallery. Detail in +[animation-parity.md](animation-parity.md). 10–16 h; the pane's timeline is a **beta blocker**. ### Interactive decks: media-action links, audio-triggered effects and click boxes - **Evidence:** the slide show plays click-triggered animations, keeps each slide's state across @@ -296,6 +311,14 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * band round the face outline (not round the extruded silhouette); no command or pane to apply 3-D). - **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 1–2 h (fitting the other rigs and materials to PowerPoint renders), plus the Format Shape page. + `prstTxWarp` presets render, their adjusts round-trip, and a selected warp shows a yellow handle + per adjust that dragging sets; still no Transform gallery or command to apply one, and arc/circle + placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation" + (partial: bevels, camera presets and rotations and face lighting render as approximations and + `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and + no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 1–2 h (Transform gallery and command), 6–10 h (3-D extrusion, + rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -329,6 +352,9 @@ sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; need matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text (6–10 h), 3-D models and stock content (6–10 h; Insert › Icons places a Lucide stroke set). +show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept, +markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile +encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set). ## UI/UX @@ -344,6 +370,7 @@ Detail in [ui-parity.md](ui-parity.md). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action Settings, Print, Insert Object…). Size and Position is one of the 28. 8–14 h. @@ -362,7 +389,16 @@ 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. +- **Dialogs**: 29 dialogs vs PowerPoint's ~60. The Font dialog and the Paragraph dialog are in + (Paragraph: alignment, first-line and hanging indents, spacing before/after, line spacing, and a + Tabs dialog, each OK one undo step). Still missing: Bullets and Numbering, Find/Replace, Replace + Fonts, Action Settings, Print, Insert Object…. 4–8 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -375,7 +411,10 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **No pen pressure or tilt for ink**; no pinch-zoom (it does not keep the point under the pointer). A two-finger twist does rotate the selected shapes. 3–6 h. +- **No pen pressure or tilt for ink**; no pinch-zoom. A trackpad swipe turns the page when the + slide fits the window, and during the show. 2–4 h for pinch-zoom. +- **Pen pressure scales ink width** when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat `0.5` still draws at the pen width). Tilt is not read. No pinch-zoom or other trackpad gestures. 2–4 h. ## Localization @@ -456,16 +495,26 @@ 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 | | 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds | | 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections | +| 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects | +| 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open | +| 2026-10-14 | minor | A trackpad swipe (and the mouse wheel, in the show) turns the page. Pinch-zoom remains | +| 2026-10-14 | minor | Paragraph dialog sets alignment, indentation, spacing and line spacing in one step; its Tabs… button opens a standalone Tabs dialog | +| 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | +| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing | +| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) | +| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 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 | Removed "Tab inside a text box ends text entry" (#78, fixed) | +| 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 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 67bc8639..c803da55 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,9 @@ # Hardware parity with PowerPoint > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page; pinch-zoom remains; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt remains; previous: minor, IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -13,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 | @@ -44,8 +47,12 @@ because it mostly follows from hardware use. |---|---|---| | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | -| Pinch to zoom, two-finger gestures | yes | no | +| Pinch to zoom, two-finger gestures | yes | pinch zoom does not keep the point under the pointer; a two-finger twist rotates the selected shapes | +| Pinch to zoom | yes | no | +| Two-finger swipe between slides | yes | yes: when the slide fits the window, and during the show. A zoomed slide still pans | | Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | +| Pinch to zoom, two-finger gestures | yes | no | +| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | pressure scales the stroke when the pointer reports it (a mouse or a missing reading draws at the pen width); tilt is not read | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | | Dictation | yes (macOS) | no | @@ -64,14 +71,18 @@ outlines every frame (#67, fix open), and the decoders run in software. | GPU slide rasterization (vello's GPU/hybrid path) | 12–20 | | Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 | | Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) | -| Pen pressure, pinch zoom | 3–6 | +| Pen tilt, pinch zoom, trackpad gestures | 2–5 | | Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) | -| **Hardware total** | **~26–45** | +| **Hardware total** | **~25–44** | ## Revision history | 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) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/mcp.md b/docs/mcp.md index e676f5de..b2f879b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -72,7 +72,7 @@ files it was given rather than guarding against other software on the computer. | `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 | @@ -87,5 +87,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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6d9c7a69..d3497c6f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,9 +1,9 @@ # PowerPoint feature checklist -> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 80%** over 191 features. +**Weighted breadth parity: 82%** over 193 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| @@ -18,15 +18,15 @@ The summary between ` | P2 | 54 | 14 | 17 | 23 | 42% | | P1 | 65 | 38 | 21 | 6 | 75% | | P2 | 54 | 13 | 16 | 25 | 39% | +| P1 | 66 | 43 | 18 | 5 | 79% | +| P2 | 55 | 16 | 20 | 19 | 47% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | | Slides | 90% | -| Views | 93% | -| Masters & themes | 85% | -| Views | 90% | +| Views | 98% | | Masters & themes | 88% | | Views | 90% | | Masters & themes | 88% | @@ -34,21 +34,25 @@ The summary between ` | Shapes | 91% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 82% | | Tables | 95% | | Tables | 92% | | Charts | 65% | -| SmartArt | 33% | -| Pictures | 52% | | SmartArt | 50% | -| Pictures | 48% | +| Pictures | 52% | | Media | 67% | | Transitions | 100% | | Animations | 79% | | Slide show | 68% | +| Transitions | 92% | +| Animations | 85% | +| Slide show | 66% | | Review | 60% | +| Review | 70% | | Draw | 33% | | Undo & clipboard | 100% | +| Draw | 50% | +| Undo & clipboard | 88% | | Automation (✱) | 100% | | Platforms (✱) | 67% | @@ -81,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 | @@ -102,7 +106,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Views | Notes Page | P1 | D | | Views | Reading View | P1 | D | | Views | Zoom, fit to window | P0 | D | -| Views | Rulers, gridlines, guides | P1 | P | +| Views | Rulers, gridlines, guides | P1 | D | | Views | Smart guides | P1 | D | | Views | Grayscale / black and white view | P2 | D | | Views | New window / arrange windows | P2 | P | @@ -164,7 +168,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Bullets and numbering | P0 | D | | Text | Picture bullets, custom bullet symbols | P2 | P | | Text | List levels (Tab / Shift+Tab) | P0 | D | -| Text | Tab stops | P2 | P | +| Text | Tab stops | P2 | D | | Text | Columns | P2 | D | | Text | Vertical anchor, margins, wrap | P0 | D | | Text | Autofit (shrink on overflow, resize shape) | P0 | D | @@ -173,6 +177,8 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Spelling check | P1 | P | | Text | Thesaurus | P2 | M | | Text | AutoCorrect and smart quotes | P2 | Y | +| Text | Thesaurus | P2 | P | +| Text | AutoCorrect and smart quotes | P2 | M | | Text | Symbols and emoji | P1 | D | | Text | Equations | P2 | M | | Text | Hyperlinks (URL, slide, email) | P1 | D | @@ -226,7 +232,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Animations | By-paragraph text builds | P1 | D | | Animations | Triggers | P2 | D | | Animations | Animation Painter | P2 | D | -| Animations | Preview in editor | P1 | P | +| Animations | Preview in editor | P1 | D | | Slide show | Full-screen show from start / current | P0 | D | | Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D | | Slide show | Presenter view (notes, next slide, timer) | P0 | P | @@ -239,10 +245,10 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slide show | Multi-monitor presenting | P1 | M | | Review | Comments (add, reply, resolve, delete) | P1 | D | | Review | Accessibility checker | P2 | D | -| Review | Compare presentations | P2 | M | +| Review | Compare presentations | P2 | P | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | -| Draw | Ink to shape / text | P2 | M | +| Draw | Ink to shape / text | P2 | P | | Undo & clipboard | Unlimited undo/redo | P0 | D | | Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D | | Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D | diff --git a/docs/roadmap.md b/docs/roadmap.md index b13af4b5..7485dd08 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,6 +16,10 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (custom show playback; previous: trivial, presenter timer) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (go to slide shows the typed number; previous: trivial, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (canvas animation preview; beta item 9 keeps the timeline; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options; previous: core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (after beta: pen pressure scales ink width, tilt remains; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -82,6 +86,8 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` | 8 | Charts: data grid, elements | 6–10 | Features | | 9 | Animation Pane timeline, preview, effect options | 5–8 | Features | | 10 | Presenter view on the right display, swap displays | 2–4 | Hardware, Slide show | +| 9 | Animation Pane timeline, preview from the selected effect, effect options | 4–7 | Features | +| 10 | Presenter view on the right display, timer controls | 3–5 | Hardware, Slide show | | 11 | Native macOS menu bar, Format Shape pane depth, start screen and window chrome fixes | 13–22 | UI/UX 60 → 75% | | 12 | Performance baseline and the obvious fixes | 4–8 | Performance | | **Total** | | **~120–210** | Ready ~55% → ~75% | @@ -97,6 +103,10 @@ Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, re GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video decode, the other 21 languages with RTL mirroring, a larger template gallery (six original starters ship), and local AI features (owner decision on models). Itemized with hours in [gaps.md](gaps.md). +GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization, hardware video +decode, pen tilt (pressure already scales ink width) and pinch zoom, the other 21 languages with +RTL mirroring, a larger original template gallery, and local AI features (owner decision on +models). Itemized with hours in [gaps.md](gaps.md). ## Milestones @@ -114,9 +124,12 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | | M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp adjust handles | +| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp gallery and command | | M10 | Views | done | grid options, New Window | | M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | | M10 | Views | done | grid options, Arrange All | +| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | +| M10 | Views | done | New Window | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | | M13 | Automation and CLI parity | done | | @@ -164,5 +177,9 @@ plan above. | 2026-10-14 | trivial | Present workflow notes custom show playback | | 2026-10-14 | trivial | Present workflow notes presenter timer pause/reset, clock and notes size. Display placement stays open | | 2026-10-14 | trivial | Present workflow notes See All Slides and zoom | +| 2026-10-13 | trivial | Canvas animation preview landed; beta item 9 keeps the timeline and preview from the selected effect | +| 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open | +| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open | +| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index 129070be..a428f369 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -9,6 +9,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (presenter timer pause/reset, clock and notes size; previous: minor, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (custom show playback; previous: minor, presenter timer pause/reset) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (go to slide shows the number while it is typed; previous: minor, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe or mouse wheel turns the page during the show; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -23,6 +24,8 @@ ready** (estimated), **10–16 h** to parity. | Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | | | Next/previous by click, arrows, Space, Page Up/Down, Enter, Backspace | yes | yes | #17 reported arrows not working in v0.3.0 AppImage; recheck | | Go to slide (number + Enter), first/last | yes | yes: the digits show as "Go to slide …" until Enter, Backspace, Esc, or 3 seconds | Slide numbers are 1-based; 0 and a number past the end do not move | +| Next/previous by trackpad swipe or mouse wheel | yes | yes | One flick turns one page. The pen ignores the flick | +| Go to slide (number + Enter), first/last | yes | yes | | | Black / white screen (B / W) | yes | yes | | | End screen, Esc to leave | yes | yes | | | Step clock: a click after a long pause plays its step (#11) | yes | yes | | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 721444e4..1bcff00a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -17,6 +17,13 @@ > **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) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Views: Grid Options landed; New Window remains; previous: hours to ~95% per audience added; ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (pen pressure scales ink; percentages unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -28,6 +35,7 @@ number. | Number | Value | Kind | |---|---|---| | **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 39%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md) | +| **Feature breadth** (weighted checklist) | **80%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md); Preview in editor re-scored 2026-10-13 | | Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | | **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | @@ -80,12 +88,12 @@ The weights are what those users actually depend on, written down so the total c | Dimension | Weight | Ready | Hours to full | Doc | Evidence | |---|---:|---:|---|---|---| -| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 79% measured. Depth lags: 37 of 191 rows are partial, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) | +| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 80% measured. Depth lags: partial rows remain, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) | | File formats | 20% | ~58% | 60–110 | [file-format-parity.md](file-format-parity.md) | PPTX reads and writes every main part and keeps unknown objects; files open in PowerPoint without repair. But it has never met a corpus of real decks, EMF/WMF/SVG pictures don't decode, comments/media/rewind settings are lost on round trip, and there is no `.ppt`, `.pptm`, `.odp`, video or GIF export | | UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 | | Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) | | Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) | -| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom | +| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen tilt, no pinch zoom | | Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps | | Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist | | Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes and 6 original starter decks; PowerPoint's large template gallery remains; no add-ins or VBA (out of scope by policy) | @@ -199,11 +207,13 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) | | Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | | Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, Arrange All | [ui-parity.md](ui-parity.md) | +| Views | 4 | 90% | ~80% | 3–5 | New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp adjust handles, WordArt depth | [ui-parity.md](ui-parity.md) | | Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx` | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | +| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp gallery and command, WordArt depth | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | | Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) | | Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | @@ -215,12 +225,14 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) | | Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | | Slide show and presenter view | 8 | 68% | ~60% | 10–16 | Second-display placement, laser from presenter view, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | +| Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview from the selected effect, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | +| Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | | Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | -| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | +| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen tilt, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | | Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) | | Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) | | Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) | -| **Total (feature areas)** | 110 | **79%** | **~62%** | **~140–240** | | | +| **Total (feature areas)** | 110 | **80%** | **~62%** | **~140–240** | | | ## What works today (evidence) @@ -240,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, @@ -282,6 +296,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Automation:** 228 commands, every one reachable from `deckcraft-cli`, the JSON control channel - **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel - **Automation:** 228 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 229 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. The menu-bar measurement and the d556ff9 snapshot still count 226. @@ -340,6 +356,14 @@ 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 | +| 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages unchanged | +| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain | +| 2026-10-14 | trivial | Draw and hardware blurbs: pen pressure scales ink width when reported; percentages and hour ranges unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 580ee6c2..a169a84b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -20,6 +20,11 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview on the editor canvas; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Format Shape fill transparency follows the selection; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -28,6 +33,10 @@ > **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) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -88,11 +97,15 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | -| Format Background pane | yes | yes | +| 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 | | 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 | +| Animation Pane | list + advanced timeline | list, reorder, timing, play the current slide on the canvas; no timeline | +| Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Size and Position, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Photo Album, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | @@ -107,6 +120,10 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Design ▸ Effects | named theme effect schemes | DeckCraft, Soft, Crisp, Lift, Glow and Plain (`design.effects`); colours and fonts stay | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media (start, end and fades follow the selection), SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Font, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 29 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Grid Options, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Find and Replace, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -116,28 +133,35 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Smart guides (edges, centres, equal spacing) | yes | yes | -| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle | +| Snap to grid, grid options, drawing guides | yes | Grid Options (`view.gridOptions`): spacing, snap to grid, snap to other objects, display grid and guides; the on-screen grid uses that spacing | | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | | Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet | +| Edit points on freeforms | yes | **no** | +| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes | | 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 | +| Exact size/position, lock aspect | yes | yes | +| Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | | Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | +| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate, -group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from -start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag +group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, the Font +dialog (Cmd+T), show from start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag precision without snapping, F6 pane cycling, ribbon KeyTips (Windows). ### Text editing keys @@ -174,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 @@ -186,13 +212,18 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Colors is in) | 8–14 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Fonts is in) | 8–14 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Action Settings, Print, Insert Object) | 8–14 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 4–8 | +| Missing dialogs (Font, Bullets and Numbering, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome and system appearance (#41, #71) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Arrange All (New Window is one extra window) | 2–3 | +| Edit points UI, crop depth | 3–6 | +| Multiple windows | 3–5 | | A screenshot comparison pass on the most-used tabs | 2–4 | -| **Total** | **~30–52** | +| **Total** | **~29–51** | ## Revision history @@ -220,6 +251,17 @@ 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 | +| 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog | +| 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | +| 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | +| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |