diff --git a/README.md b/README.md index b73a317b..7125a573 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. diff --git a/ROADMAP.md b/ROADMAP.md index aa5060f9..db0d810f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Background picture options; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: copy and paste rich text and pictures with other apps; previous: hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (animation preview on the canvas; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Shape fill transparency follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -221,6 +222,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` | | 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% | | 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero | +| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 | +| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) | | 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) | | 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Breadth 79% → 80%, Animations 79% → 85% | | 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | @@ -240,6 +243,9 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) | | 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) | | 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items | +| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes | +| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) | +| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | @@ -248,6 +254,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) | +| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line | +| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | @@ -298,6 +306,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | trivial | Progress log: Design ▸ Effects | | 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged | | 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection | +| 2026-10-14 | trivial | Progress log: Format Background picture and solid options | +| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) | | 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps | | 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% | | 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection | @@ -308,8 +318,10 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: slide show for interactive decks | +| 2026-10-12 | minor | Progress log: table cell editing and border resizing | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | +| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs new file mode 100644 index 00000000..d6158f46 --- /dev/null +++ b/apps/deckcraft-cli/src/help.rs @@ -0,0 +1,180 @@ +pub struct Subcommand { + pub name: &'static str, + pub usage: &'static str, + pub summary: &'static str, + pub details: &'static str, +} + +pub const SUBCOMMANDS: &[Subcommand] = &[ + Subcommand { + name: "info", + usage: "info FILE", + summary: "print a presentation's slides, layouts and theme as JSON", + details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes, +notes, transition), its layouts, theme fonts, sections and slide size. + +The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command: + + deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'", + }, + Subcommand { + name: "render", + usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT", + summary: "draw slides as PNG images", + details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first; +OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per +slide. Prints each image's path and size. + + --slide N the slide at index N; indices start at 0, and 0 is the default + --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 + --edit draw what the editor shows: placeholder prompts and their dashed outlines + + deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", + }, + Subcommand { + name: "render-transition", + usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png", + summary: "draw frames of the transition into a slide as PNG images", + details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N), +as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as +OUT-t0.250.png and so on. Prints each image's path, size, transition and time. + + --slide N the slide the transition leads into; indices start at 0 + --t T,T... the times to draw, comma-separated + --width PX the image width in pixels, twice the slide width in points by default + + deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png", + }, + Subcommand { + name: "convert", + usage: "convert IN OUT", + summary: "save a presentation in the format OUT's extension names", + details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names: +.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an +animated GIF with one frame per slide. Other extensions are refused. Prints OUT. + + deckcraft-cli convert deck.deckcraft deck.pptx", + }, + Subcommand { + name: "run", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]", + summary: "run commands on a presentation, then save or export it", + details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample, +else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps +everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the +current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per +slide). --export-params passes file.export options as one JSON object, such as +'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF +(`deckcraft-cli describe file.export` lists them). + +Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one +JSON object. Quote it for the shell. A command that needs no parameters is just its id. + + deckcraft-cli run \\ + --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\ + --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\ + --cmd 'format.size={\"size\":28}' \\ + --save q3.deckcraft + +`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its +parameters. + +What the ids don't say: +- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is + [x, y, width, height]. +- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current; + slide.go {\"index\": N} makes slide N current. +- Shape ids are looked up on the current slide only. +- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts + on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands + take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on. + +If a command fails, run stops there and saves nothing. --print shows each command's answer, such as +the id of an inserted shape.", + }, + Subcommand { + name: "commands", + usage: "commands [FILTER]", + summary: "list the commands run can use, one per line", + details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in +the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case. + + deckcraft-cli commands format. + +In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what +the command answers. `deckcraft-cli describe COMMAND` spells one command out.", + }, + Subcommand { + name: "describe", + usage: "describe COMMAND", + summary: "explain one command and its parameters", + details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run +calls it. + + deckcraft-cli describe shape.insert", + }, + Subcommand { + name: "app", + usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]", + summary: "run a command in the DeckCraft app started with --control", + details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default) +and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD +[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object. + + deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}' + deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'", + }, + Subcommand { + name: "mcp", + usage: "mcp [--connect PORT] [--sample]", + summary: "serve the commands to an agent over MCP on stdio", + details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a +window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT. +--sample opens the sample presentation.", + }, + Subcommand { + name: "bench", + usage: "bench (FILE | --sample) [--runs N] [--json]", + summary: "time opening, drawing, saving and exporting a presentation", + details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to +save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the +median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json. + + deckcraft-cli bench deck.pptx --runs 10", + }, + Subcommand { + name: "links", + usage: "links", + summary: "print the DeckCraft community and project links", + details: "Prints the DeckCraft community and project links.", + }, + Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." }, +]; + +pub fn page(name: &str) -> Option { + 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 @@ -62,24 +54,17 @@ fn main() -> ExitCode { Some("render-transition") => report(render_transition(rest)), Some("convert") => report(convert(rest)), Some("run") => report(run(rest)), - Some("--help" | "-h" | "help") => { - outln!("{USAGE}"); - ExitCode::SUCCESS - } - Some("commands") if rest.first().is_some_and(|a| is_help(a)) => { - outln!("{USAGE}"); - ExitCode::SUCCESS - } Some("commands") => { - let s = Session::new(); let f = rest.first().map(|x| x.to_lowercase()); - let list: Vec = s - .commands() - .into_iter() + for c in deckcraft_engine::command_specs() + .iter() .filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str()))) - .map(|c| serde_json::to_value(c).unwrap_or_default()) - .collect(); - outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + { + match c.params.strip_prefix("{}") { + Some(answer) => outln!("{} — {}{answer}", c.id, c.label), + None => outln!("{} — {}: {}", c.id, c.label, c.params), + } + } ExitCode::SUCCESS } Some("describe") => report(describe(rest.first().map(String::as_str))), @@ -90,11 +75,8 @@ fn main() -> ExitCode { outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); ExitCode::SUCCESS } - _ => { - eprintln!("{USAGE}"); - eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); - ExitCode::FAILURE - } + Some(other) => unknown(other), + None => usage(), } } @@ -102,6 +84,22 @@ fn is_help(arg: &str) -> bool { matches!(arg, "--help" | "-h") } +/// The help page of subcommand `name`, else the overview. +fn help_page(name: &str) -> String { + help::page(name).unwrap_or_else(help::overview) +} + +fn unknown(sub: &str) -> ExitCode { + eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n"); + usage() +} + +fn usage() -> ExitCode { + eprintln!("{}", help::overview()); + eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); + ExitCode::FAILURE +} + fn report(r: Result<(), String>) -> ExitCode { match r { Ok(()) => ExitCode::SUCCESS, @@ -226,7 +224,7 @@ fn bench(args: &[String]) -> Result<(), String> { fn info(args: &[String]) -> Result<(), String> { if args.first().is_some_and(|a| is_help(a)) { - outln!("{USAGE}"); + outln!("{}", help_page("info")); return Ok(()); } let path = args.first().ok_or("info needs a FILE")?; @@ -254,7 +252,7 @@ fn render(args: &[String]) -> Result<(), String> { "--edit" => edit = true, "--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("render")); return Ok(()); } other if other.starts_with('-') => return Err(format!("unknown option `{other}`")), @@ -403,7 +401,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result, String> { fn convert(args: &[String]) -> Result<(), String> { if args.first().is_some_and(|a| is_help(a)) { - outln!("{USAGE}"); + outln!("{}", help_page("convert")); return Ok(()); } let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; @@ -412,6 +410,8 @@ fn convert(args: &[String]) -> Result<(), String> { open(&mut s, Some(input), false)?; let r = if matches!(fmt, "png" | "jpeg") { s.execute("file.export", &json!({"path": out, "slide": 0})) + } else if fmt == "gif" { + s.execute("file.export", &json!({"path": out})) } else { s.execute("file.saveAs", &json!({"path": out})) }; @@ -436,18 +436,26 @@ fn run(args: &[String]) -> Result<(), String> { let mut cmds = vec![]; let mut save = None; let mut export = None; + let mut export_params = json!({}); let mut print = false; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?), "--sample" => sample = true, - "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?), + "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?), "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), + "--export-params" => { + let j = it.next().ok_or("--export-params needs a JSON object")?; + export_params = serde_json::from_str::(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?; + if !export_params.is_object() { + return Err("--export-params must be a JSON object".into()); + } + } "--print" => print = true, "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("run")); return Ok(()); } other => return Err(format!("unknown option `{other}`")), @@ -472,22 +480,76 @@ fn run(args: &[String]) -> Result<(), String> { s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?; } if let Some(p) = export { - s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?; + export_params["path"] = json!(p); + s.execute("file.export", &export_params).map_err(|e| e.to_string())?; + } else if export_params != json!({}) { + return Err("--export-params needs --export OUT".into()); } Ok(()) } fn describe(id: Option<&str>) -> Result<(), String> { if id.is_some_and(is_help) { - outln!("{USAGE}"); + outln!("{}", help_page("describe")); return Ok(()); } let id = id.ok_or("describe needs a command id")?; - let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?; - outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params); + let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?; + let mut head = format!("{}: {}", c.id, c.label); + if !c.menu.is_empty() { + head.push_str(&format!(", in {}", c.menu.join(" ▸ "))); + } + if let Some(k) = c.shortcut { + head.push_str(&format!(", shortcut {k}")); + } + outln!("{head}.\n"); + let (args, answer) = c.params.split_once('→').unwrap_or((c.params, "")); + let parsed = deckcraft_engine::cmd::parse_params(c.params); + match &parsed { + Err(_) => outln!("Parameters: {}", args.trim()), + Ok(params) if params.is_empty() => outln!("Takes no parameters."), + Ok(params) => print_params(params), + } + let params = parsed.unwrap_or_default(); + if let Some(acts) = c.acts_on() { + outln!("{acts}"); + } + let remark = remark(args); + if !remark.is_empty() { + outln!("Note: {remark}"); + } + if !answer.trim().is_empty() { + outln!("Answers with {}.", answer.trim()); + } + let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional)); + let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) }; + outln!("\nRun it with: deckcraft-cli run --cmd {call}"); Ok(()) } +fn print_params(params: &[deckcraft_engine::cmd::Param]) { + let name = |p: &deckcraft_engine::cmd::Param| { + let or = if p.or { "or " } else { "" }; + let optional = if p.optional { ", optional" } else { "" }; + format!("{or}{}{optional}", p.name) + }; + let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default(); + let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0; + outln!("Parameters:"); + for (k, p) in params.iter().enumerate() { + if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) { + outln!(" {}", if p.object == 0 { "either:" } else { "or:" }); + } + let indent = if sets { " " } else { " " }; + outln!("{}", format!("{indent}{: &str { + let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim(); + after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after) +} + fn app(args: &[String]) -> Result<(), String> { use deckcraft_mcp::{Backend, Remote, control_addr}; let mut port = deckcraft_mcp::DEFAULT_PORT.to_string(); @@ -499,7 +561,7 @@ fn app(args: &[String]) -> Result<(), String> { "--port" => port = it.next().cloned().ok_or("--port needs a value")?, "--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?), "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("app")); return Ok(()); } other => rest.push(other.to_string()), @@ -531,7 +593,7 @@ fn mcp(args: &[String]) -> Result<(), String> { "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), "--sample" => sample = true, "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("mcp")); return Ok(()); } other => return Err(format!("unknown mcp option `{other}`")), @@ -554,6 +616,8 @@ fn mcp(args: &[String]) -> Result<(), String> { #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; fn args(a: &[&str]) -> Vec { @@ -582,6 +646,46 @@ mod tests { assert!(describe(Some("--help")).is_ok()); assert!(mcp(&args(&["-h"])).is_ok()); } + + #[test] + fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() { + let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png"); + let _ = std::fs::remove_file(&out); + for flag in ["--bogus", "-x"] { + let args: Vec = ["--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 new file mode 100644 index 00000000..d04e55c7 --- /dev/null +++ b/apps/deckcraft-cli/tests/cli.rs @@ -0,0 +1,139 @@ +use std::process::{Command, Output}; + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn stdout(o: &Output) -> String { + assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr)); + String::from_utf8_lossy(&o.stdout).into_owned() +} + +fn stderr(o: &Output) -> String { + assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout)); + String::from_utf8_lossy(&o.stderr).into_owned() +} + +const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"]; + +fn words(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +#[test] +fn an_unknown_subcommand_is_named_before_the_overview() { + for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] { + let err = stderr(&cli(&args)); + assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}"); + } +} + +#[test] +fn no_subcommand_shows_the_overview_and_fails() { + assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on")); +} + +#[test] +fn help_lists_every_subcommand_with_a_run_example() { + for flag in ["help", "--help", "-h"] { + let out = stdout(&cli(&[flag])); + for sub in SUBCOMMANDS { + assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}"); + } + assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}"); + } +} + +#[test] +fn every_subcommand_answers_help_and_does_nothing_else() { + let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for sub in SUBCOMMANDS { + for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] { + let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}"); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing"); + std::fs::remove_dir_all(&dir).unwrap(); + for args in [["help", "version"], ["--version", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}"); + } + for args in [["help", "help"], ["help", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}"); + } +} + +#[test] +fn run_help_says_what_the_ids_do_not() { + let out = words(&stdout(&cli(&["run", "--help"]))); + for fact in [ + "COMMAND is a command id, then `=`, then the command's parameters as one JSON object.", + "points on a 960×540 slide", + "Slide indices start at 0.", + "Shape ids are looked up on the current slide only.", + "A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.", + "format.* acts on the selected text or shapes", + ] { + assert!(out.contains(fact), "{fact}: {out}"); + } +} + +#[test] +fn describe_spells_out_a_command() { + assert_eq!( + stdout(&cli(&["describe", "shape.insert"])), + "shape.insert: Shapes, in Insert ▸ Illustrations. + +Parameters: + preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets) + rect [x,y,w,h] pt + text, optional string + +Run it with: deckcraft-cli run --cmd 'shape.insert={…}' +" + ); + assert_eq!( + stdout(&cli(&["describe", "format.size"])), + "format.size: Font Size, in Home ▸ Font. + +Parameters: + size pt +Acts on the selected text or shapes. + +Run it with: deckcraft-cli run --cmd 'format.size={…}' +" + ); + assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")); + assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n")); + assert_eq!( + stdout(&cli(&["describe", "shape.setDefault"])), + "shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n" + ); +} + +#[test] +fn describe_shows_alternative_sets_of_parameters() { + assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n")); + let remove = stdout(&cli(&["describe", "animation.remove"])); + assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}"); + assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}"); + assert!(stdout(&cli(&["describe", "shape.move"])).contains( + " either: + dx, optional pt (relative) + dy, optional pt (relative) + ids, optional + or: + x, optional pt (absolute) + y, optional pt (absolute) + ids, optional +" + )); +} + +#[test] +fn commands_lists_one_line_per_command() { + assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n"); + assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n"); + assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n"); +} 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-web/src/web.rs b/apps/deckcraft-web/src/web.rs index 8fa95918..dabad8af 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -181,6 +181,7 @@ fn mime_for(name: &str) -> &'static str { Some("png") => "image/png", Some("jpg" | "jpeg") => "image/jpeg", Some("pdf") => "application/pdf", + Some("gif") => "image/gif", Some("txt") => "text/plain", Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation", Some("deckcraft") => "application/zip", 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/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/design.rs b/crates/engine/src/cmd/design.rs index ddae3f58..b79f94ca 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/file.rs b/crates/engine/src/cmd/file.rs index d7356b23..127ae31f 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 { @@ -334,10 +342,30 @@ fn mark_saved(s: &mut Session, path: Option) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - // Write to a temporary sibling then rename, so a failed save never truncates the old file. - let tmp = format!("{path}.saving"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); + // Write to a uniquely named temporary sibling, created exclusively (never following or reusing an + // existing file or symlink), then rename it over the destination so a failed save never truncates + // the old file and concurrent saves never share temporary bytes. + let mut attempts = 0; + let (tmp, mut file) = loop { + let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(f) => break (tmp, f), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, + Err(e) => return Err(err(e)), + } + }; + let written = file.write_all(bytes).and_then(|()| file.sync_all()); + drop(file); + let result = written.and_then(|()| std::fs::rename(&tmp, path)); + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp); + return Err(err(e)); + } + Ok(()) } #[cfg(target_arch = "wasm32")] fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> { @@ -470,6 +498,23 @@ fn export(s: &mut Session, p: &Value) -> Result { write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } + "gif" => { + // `slides` may be a range, as for PDF ("1-3,5"). + let mut gp = p.clone(); + if let Some(text) = p.get("slides").and_then(Value::as_str) { + gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?); + } + let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?; + write_file(&path, &out.bytes)?; + Ok(json!({ + "path": path, + "bytes": out.bytes.len(), + "frames": out.frames, + "width": out.width, + "height": out.height, + "secondsPerSlide": f64::from(out.delay_cs) / 100.0, + })) + } "pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => { let bytes = save_bytes(&st.doc, &format)?; write_file(&path, &bytes)?; @@ -595,3 +640,43 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result { #[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/format.rs b/crates/engine/src/cmd/format.rs index 515ae069..fd7a5232 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -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", @@ -214,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 ), @@ -1206,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(), })) 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 86300d58..cb0b41ef 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -43,6 +43,9 @@ pub struct CommandSpec { /// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu). pub menu: &'static [&'static str], pub shortcut: Option<&'static str>, + /// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks + /// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values + /// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer. pub params: &'static str, pub enabled: Enabled, pub run: Run, @@ -63,6 +66,28 @@ pub struct CommandInfo { } impl CommandSpec { + /// Holds because `Session::execute` skips the selection check when `id` or `ids` is given. + pub fn acts_on(&self) -> Option { + let keys = param_keys(self.params).unwrap_or_default(); + let named = if keys.contains(&"ids") { + Some("the shapes in `ids`") + } else if keys.contains(&"id") { + Some("the shape in `id`") + } else { + None + }; + let reason = (self.enabled)(&Session::with_new()).err()?; + Some(match (reason.as_str(), named) { + (SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(), + (NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."), + (NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(), + (SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."), + (SELECT_TWO, None) => "Acts on two or more selected shapes.".into(), + (NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(), + _ => return None, + }) + } + pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); CommandInfo { @@ -89,6 +114,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> { } pub const NOTHING_SELECTED: &str = "nothing selected"; pub const SELECT_TWO: &str = "select two or more objects"; +pub const NO_TEXT_POINT: &str = "no text insertion point"; +pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape"; pub fn has_selection(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) } @@ -99,17 +126,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> { } pub fn has_text(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) } + if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) } } -/// Text editing or shapes with text selected (formatting commands apply to either). +/// Text editing or selected shapes (formatting commands apply to either). pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { - Ok(()) - } else { - Err("select text or a shape".into()) - } + if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) } } + pub fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } @@ -167,6 +191,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } +pub(crate) fn param_keys(doc: &str) -> std::result::Result, 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 52045e99..4358a004 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -46,7 +46,7 @@ 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!(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", diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 16e70f6e..7faffb34 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -39,8 +39,16 @@ pub fn specs() -> Vec { ink_to_shape ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), - cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), - cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), + cmd!( + "shape.move", + "Move", + [], + None, + "{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}", + has_selection, + move_by + ), + cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds), cmd!( "shape.rotate", @@ -121,7 +129,15 @@ pub fn specs() -> Vec { 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", 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 d4fd26f3..6e743618 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::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; -use deckcraft_model::{Presentation, ShapeId, ShapeKind}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use serde_json::{Value, json}; use super::*; @@ -114,7 +115,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text(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 @@ -1104,7 +1129,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { @@ -1194,7 +1219,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { 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/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/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 b6c0bbe7..9a85a94c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod compare; pub mod connect; pub mod drawtable; pub mod fonts; +pub mod gif_export; pub mod guard; pub mod icons; pub mod inkshape; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0f407d24..bb93bb67 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -293,7 +293,8 @@ fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() { assert_eq!(f("a.pdf"), Some("pdf")); assert_eq!(f("a.jpg"), Some("jpeg")); assert_eq!(f("a.txt"), Some("outline")); - for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] { + assert_eq!(f("a.gif"), Some("gif")); + for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] { assert_eq!(f(path), None, "{path}"); } } @@ -567,6 +568,300 @@ fn grown_table_frame_covers_its_drawn_rows() { assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. }))); } +/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`. +fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId { + let rows = data.as_array().map_or(1, Vec::len); + let w: f64 = cols.iter().sum(); + let r = + s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + for (c, cw) in cols.iter().enumerate() { + s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap(); + } + s.execute("edit.deselect", &json!({})).unwrap(); + id +} + +fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table { + match &s.doc().unwrap().shape(id).unwrap().kind { + deckcraft_model::ShapeKind::Table(t) => t.clone(), + k => panic!("not a table: {k:?}"), + } +} + +fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None } +} + +fn text_sel(s: &Session) -> Option { + 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(); @@ -2362,6 +2657,306 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { Some((u32::from_be_bytes(w), u32::from_be_bytes(h))) } +/// A blank slide holding one text box being edited, with smart guides and grid snapping off so +/// drags move by exactly the pointer delta. Returns the session, the box id and its box. +fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap(); + if rot != 0.0 { + s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap(); + } + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let x = text_box_xfrm(&s, id); + (s, id, x) +} + +/// Slide point for a point in the box's own (rotated) frame. +fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) { + let p = x.affine() * deckcraft_geom::Point::new(lx, ly); + (p.x, p.y) +} + +fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; + let down = s.pointer(ev(PointerKind::Down, from)).unwrap(); + let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0); + s.pointer(ev(PointerKind::Drag, mid)).unwrap(); + s.pointer(ev(PointerKind::Drag, to)).unwrap(); + s.pointer(ev(PointerKind::Up, to)).unwrap(); + down +} + +#[test] +fn dragging_the_frame_of_edited_text_moves_the_box() { + let (mut s, id, x) = editing_text_box(0.0); + assert!(x.h > 12.0, "{x:?}"); + let sid = deckcraft_model::ShapeId(id as u32); + // Hovering the band on either side of the edge says "move"; the inside says "text". + for ly in [-2.0, 2.0, x.h + 2.0] { + let (px, py) = on_box(&x, x.w / 4.0, ly); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}"); + } + let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true })); + + // Press on the top edge, just outside, and drag: the box moves, text editing ends. + let undo_before = s.doc().unwrap().history.undo.len(); + let from = on_box(&x, x.w / 4.0, -1.0); + let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0)); + assert_eq!(down["hit"], json!({"kind": "frame", "id": id})); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!((moved.w, moved.h), (x.w, x.h)); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + // One undo step, which puts the box back. + assert_eq!(st.history.undo.len(), undo_before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_box_xfrm(&s, id), x); + + // The inner side of the band works too; a click there without a drag selects the box whole + // and changes nothing. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0); + let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }; + s.pointer(ev(PointerKind::Down)).unwrap(); + s.pointer(ev(PointerKind::Up)).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + assert_eq!(st.history.undo.len(), undo_before); + assert_eq!(text_box_xfrm(&s, id), x); +} + +#[test] +fn dragging_inside_edited_text_selects_text() { + let (mut s, id, x) = editing_text_box(0.0); + let undo_before = s.doc().unwrap().history.undo.len(); + let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0)); + assert!(down.get("caret").is_some(), "{down}"); + assert_eq!(text_box_xfrm(&s, id), x); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().expect("still editing"); + assert!(t.is_range(), "{t:?}"); + assert_eq!(t.ordered(), ((0, 0), (0, 11))); + assert_eq!(st.history.undo.len(), undo_before); +} + +#[test] +fn frame_of_rotated_edited_text_follows_the_rotation() { + let (mut s, id, x) = editing_text_box(30.0); + assert_eq!(x.rot, 30.0); + let sid = deckcraft_model::ShapeId(id as u32); + // The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation. + let from = on_box(&x, x.w / 4.0, x.h + 1.0); + assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid })); + let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0)); + assert_eq!(down["hit"]["kind"], "frame"); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!(moved.rot, 30.0); + // Inside the rotated box, pressing still places the caret. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let mx = text_box_xfrm(&s, id); + let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert!(r.get("caret").is_some(), "{r}"); + assert_eq!(text_box_xfrm(&s, id), mx); + // Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press + // there leaves editing instead of moving the caret. + let off = on_box(&mx, mx.w / 2.0, -mx.h); + let off_pt = deckcraft_geom::Point::new(off.0, off.1); + assert!(mx.bounds().contains(off_pt)); + assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid })); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap(); + assert!(r.get("caret").is_none(), "{r}"); + assert!(s.doc().unwrap().selection.text.is_none()); + assert_eq!(text_box_xfrm(&s, id), mx); +} + +#[test] +fn resize_handles_beat_the_frame_of_edited_text() { + let (mut s, id, x) = editing_text_box(0.0); + // The SE corner lies in the frame band, but its handle wins. + let from = on_box(&x, x.w, x.h); + let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1)); + assert_eq!(down["hit"]["kind"], "resize"); + let sized = text_box_xfrm(&s, id); + assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}"); + assert_eq!((sized.x, sized.y), (x.x, x.y)); +} + +#[test] +fn format_language_marks_the_selected_text() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola mundo"})).unwrap(); + s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); + s.execute("format.language", &json!({"lang": "es-ES"})).unwrap(); + assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES"); + let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect(); + assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]); +} + +#[test] +fn every_params_doc_names_its_keys() { + for c in command_specs() { + assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params)); + } + assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]); + assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]); +} + +#[test] +fn a_params_doc_spells_out_each_parameter() { + use cmd::Param; + let p = |name, optional, about, or, object| Param { name, optional, about, or, object }; + assert_eq!( + cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(), + [ + p("preset", false, "rect|roundRect", false, 0), + p("rect", true, "[x,y,w,h] pt", false, 0), + p("index", true, "(else selected shapes)", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(), + [ + p("color", true, "", false, 0), + p("none", true, "true", true, 0), + p("gradient", true, "{stops, kind?: a|b}", false, 0), + p("ids", true, "", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{name} | {major, minor}").unwrap(), + [p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)] + ); +} + +#[test] +fn what_a_command_acts_on_names_only_keys_it_takes() { + for c in command_specs() { + let keys = cmd::param_keys(c.params).unwrap(); + let said = c.acts_on().unwrap_or_default(); + assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id); + assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id); + } + let acts = |id: &str| find_command(id).and_then(|c| c.acts_on()); + assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes.")); + assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes.")); + assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes.")); + assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes.")); + assert_eq!(acts("slide.new"), None); +} + +/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the +/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land +/// in the cell where it is drawn, and borders still resize. +#[test] +fn tables_with_a_file_defined_style_edit_and_resize() { + use deckcraft_model::style::{ColorRef, Fill}; + use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef}; + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let built_in = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + // The table's style, defined by the file: a plain (not bold) header, a filled body, a + // background. + { + let style = table_of(&s, id).style; + let d = s.doc_mut().unwrap(); + let doc = std::sync::Arc::make_mut(&mut d.doc); + let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)))); + doc.table_styles.push(TableStyleDef { + id: style, + name: "File style".into(), + parts: vec![ + (TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }), + (TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }), + ], + background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }), + raw: String::new(), + }); + } + s.execute("edit.deselect", &json!({})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}"); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // A double-click selects the word drawn there. + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11)))); + // A click in another cell moves the caret there. + s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap(); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); + // Border drag, while editing: the columns resize, editing goes on. + s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); +} + +#[test] +fn editing_notes_keeps_their_formatting_through_pptx() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + // A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint. + let mut p = deckcraft_model::defaults::new_presentation(None); + let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, + point("First point"), + point("Second point"), + ], + ..Default::default() + }; + let bytes = deckcraft_pptx::export(&p).unwrap(); + let mut s = session(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap(); + // Type a word into the second bullet, as the notes pane does. + s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap(); + let check = |notes: &TextBody| { + assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point"); + assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true)); + for para in ¬es.paragraphs[1..] { + assert_eq!(para.level, 1); + assert_eq!(para.props.margin_left, Some(22.5)); + assert_eq!(para.props.indent, Some(-22.5)); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() })); + } + }; + check(&s.doc().unwrap().doc.slides[0].notes); + let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap(); + check(&reopened.slides[0].notes); + // slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are. + let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone(); + s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain"); + s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap(); + check(&s.doc().unwrap().doc.slides[0].notes); + assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err()); +} + #[test] fn autocorrect_options_as_you_type() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 15a51eb0..dbc72dcb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -110,6 +110,21 @@ pub enum Hit { id: ShapeId, text: bool, }, + /// The frame of the box whose text is being edited (a band on either side of its edge): + /// pressing there leaves text editing and drags the box, like PowerPoint. + Frame { + id: ShapeId, + }, + /// The right edge of table column `col` (dragging it resizes the column). + TableColumn { + id: ShapeId, + col: usize, + }, + /// The bottom edge of table row `row` (dragging it resizes the row). + TableRow { + id: ShapeId, + row: usize, + }, } #[derive(Clone, Debug)] @@ -177,6 +192,18 @@ enum Drag { TextSelect { shape: ShapeId, }, + /// Dragging a table border: the right edge of column `index`, or the bottom edge of row + /// `index`. `start` is the table-local press point, `size` the column's width or the row's + /// drawn height then, `frame` where the table was drawn. + TableBorder { + id: ShapeId, + column: bool, + index: usize, + start: Point, + size: f64, + frame: Affine, + moved: bool, + }, Ink { points: Vec<(f64, f64, f32)>, }, @@ -382,6 +409,13 @@ impl Session { } } } + // The frame of the text being edited: grabbing it moves the box. + if let Some(e) = edited_box(st) + && e.movable + && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + { + return Some(Hit::Frame { id: e.id }); + } // Shapes, topmost first. // Locked objects are still picked (to format them or type in them); they just don't move. for sh in st.shapes().iter().rev() { @@ -510,30 +544,10 @@ impl Session { } ToolKind::Select => {} } - let st = self.doc()?; // Clicking inside the text being edited moves the caret / starts a text selection. - if let Some(t) = st.selection.text.clone() - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) - { - let x = xfrm_of(&st.doc, &st.selection, sh); - if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. })) - { - let pos = text_pos(st, sh, p); - let shift = mods.shift; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = pos; - if !shift { - tt.anchor = pos; - } - } - })?; - self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } + if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { + self.tool.drag = Some(Drag::TextSelect { shape }); + return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; @@ -567,6 +581,8 @@ impl Session { } let st = self.doc()?; match hit { + Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, + Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Size"); @@ -592,6 +608,17 @@ impl Session { self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Frame { id }) => { + // Like PowerPoint: the box leaves text editing and is selected whole (a click + // without a drag stops there), and dragging moves it as one undo step. + let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); + } Some(Hit::Warp { id, index }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); let found = st.shape(id).and_then(|sh| { @@ -624,8 +651,10 @@ impl Session { let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); // A click (no drag) on text puts the caret there; unselected text boxes and - // placeholders take the caret straight away like PowerPoint. - let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); + // placeholders take the caret straight away like PowerPoint. A click in a selected + // table puts the caret in the clicked cell. + let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); + let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p)); let ids2 = ids.clone(); self.select(|_, sel| { sel.shapes = ids2; @@ -874,9 +903,10 @@ impl Session { self.tool.preview = Some(x); } Drag::TextSelect { shape } => { + // The selection stays in the shape or table cell where it started. let st = self.doc()?; - if let Some(sh) = st.shape(shape) { - let pos = text_pos(st, sh, p); + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); self.select(|_, sel| { if let Some(t) = sel.text.as_mut() { t.caret = pos; @@ -884,6 +914,26 @@ impl Session { })?; } } + Drag::TableBorder { id, column, index, start, size, frame, moved } => { + let local = frame.inverse() * p; + let d = if column { local.x - start.x } else { local.y - start.y }; + if !moved && d.abs() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if column { "Column Width" } else { "Row Height" }); + } + // An inner column border moves between its two columns (the table keeps its + // width); the right edge widens the last column and the table. A row's height is + // a minimum: rows still grow to fit their text. + if column { + let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); + self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; + } else { + self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); + } Drag::Freeform { mode, mut points, pressed } => { // Dragging scribbles (except for curves, which only take clicked points). if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { @@ -939,11 +989,8 @@ impl Session { }; } Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { - // Plain click on text: caret at the click. - let st = self.doc()?; - if let Some(sh) = st.shape(id) { - let pos = text_pos(st, sh, at); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; + // Plain click on text: caret at the click (in a table, in the clicked cell). + if self.edit_text_at(id, at)? { out = json!({"editing": id}); } } @@ -1073,8 +1120,12 @@ impl Session { self.tool.sticky = false; return Ok(Value::Null); } - let st = self.doc()?; - if st.selection.text.is_some() { + // The double-click's second press started a click (caret on release): this replaces it. + if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { + self.tool.drag = None; + } + // In the text being edited: select the word double-clicked. + if self.place_caret(p, tol, false)?.is_some() { return self.execute("text.selectWord", &json!({})); } match self.hit_test(p, tol) { @@ -1082,18 +1133,12 @@ impl Session { let st = self.doc()?; let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; match &sh.kind { - ShapeKind::Table(_) => { - let x = xfrm_of(&st.doc, &st.selection, &sh); - let local = x.affine().inverse() * p; - if let ShapeKind::Table(t) = &sh.kind { - let cell = deckcraft_render_cell(t, &table_rows(st, t), local); - return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]})); + ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + // Caret at the double-click (in a table, in the clicked cell), then select + // the word there. + if !self.edit_text_at(id, p)? { + return Ok(Value::Null); } - Ok(Value::Null) - } - ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { - let pos = text_pos(st, &sh, p); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; self.execute("text.selectWord", &json!({})) } ShapeKind::Group { .. } => { @@ -1147,6 +1192,9 @@ impl Session { | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } | Hit::Vertex { id, .. } + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. } | Hit::Warp { id, .. }, ) => Some(*id), None => None, @@ -1166,6 +1214,96 @@ impl Session { })) } + /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection + /// grows to it). In a table being edited, a click in another cell moves the caret into that + /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text + /// (or is on a handle or a table border). + fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { + 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(_))) } @@ -1504,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 } @@ -1617,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. @@ -1685,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. @@ -1704,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/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/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/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/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 a786b5fb..648e6f8f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -153,10 +153,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); - let slide_rect = content_rect.translate(-offset); + // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). The + // zoom anchor keeps the unsnapped rect, so snapping never accumulates into drift. + let ppp = ui.ctx().pixels_per_point(); + let exact_rect = content_rect.translate(-offset); + let slide_rect = crate::textures::device_pixel_rect(exact_rect, ppp); ui.data_mut(|d| { d.insert_temp(scroll_id, offset); - d.insert_temp(last_id, (slide_rect, scale)); + d.insert_temp(last_id, (exact_rect, scale)); }); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; @@ -164,7 +168,6 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let painter = ui.painter_at(avail); // Shadow + slide. painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); - let ppp = ui.ctx().pixels_per_point(); let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); let gifs = match app.session.active() { @@ -425,7 +428,7 @@ fn master_tex( layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) + Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { @@ -760,6 +763,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { @@ -768,7 +773,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { egui::CursorIcon::Move } } - (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, + (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, Some(_) => egui::CursorIcon::ResizeVertical, @@ -887,21 +892,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } } - // Caret / text selection. - if let Some(ts) = &editing - && !ts.notes - && ts.cell.is_none() - && let Some(sh) = st.shape(ts.shape) - && let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts) - { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - // Where the text is drawn: flips don't mirror it, vertical text is turned. - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - let (s0, s1) = ts.ordered(); - for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) { - let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q)); - painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE)); + // Caret / text selection, in a shape or a table cell. + if let Some(ts) = &editing { + for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { + painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE)); } // An input method's composition: underlined in place, thicker under the part being converted. for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { @@ -916,30 +910,15 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } } - // Table cell editing: outline the cell. + // Table cell editing: outline the cell (merged cells as drawn). if let Some(ts) = &editing && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) - && let deckcraft_model::ShapeKind::Table(tb) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + && let Some(cr) = f.cell_rect(r, c) { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let heights = deckcraft_engine::tools::table_rows(st, tb); - let y0: f64 = heights.iter().take(r).sum(); - let x0: f64 = tb.cols.iter().take(c).sum(); - let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0)); - let a = x.affine(); - let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q)); + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] + .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); - // The composition is in the cell text; underline it where that text is drawn (#101). - for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { - painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret)); - } - if !ts.is_range() { - let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); - if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret)); - } - } } // Smart guides, marquee, previews. for g in &app.session.tool.guides { @@ -1029,6 +1008,24 @@ fn reported_pressure(ui: &Ui) -> Option { }) } +/// 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 { @@ -1239,6 +1236,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("shape.lock", json!({"locked": !locked})); ui.close(); } + let picture = app + .session + .active() + .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); + if picture && ui.button("Change Picture…").clicked() { + let _ = app.run("app.changePictureDialog", json!({})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); @@ -1346,6 +1351,7 @@ mod tests { use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; use egui_kittest::Harness; + use super::*; use crate::{Services, SlideApp}; fn harness() -> Harness<'static, SlideApp> { @@ -1373,8 +1379,10 @@ mod tests { h.run_steps(30); } + /// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel + /// grid (#184). fn assert_near(a: Pos2, b: Pos2, what: &str) { - assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}"); + assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}"); } /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. @@ -1430,6 +1438,92 @@ mod tests { assert_near(mid, slide_point(&h, c), "zoom out"); } + fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) { + let mut s = Session::with_new(); + let data = json!([["Hello world", "b"], ["c", ""]]); + let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("edit.deselect", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + (harness, id) + } + + /// Screen position of slide point (x, y). + fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 { + Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y)) + } + + /// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click). + fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + let t = harness.ctx.input(|i| i.time) + 0.05; + harness.input_mut().time = Some(t); + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + } + + /// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked. + #[test] + fn double_click_in_a_table_cell_shows_the_caret_there() { + let (mut harness, id) = app_with_table(); + // The empty cell (1, 1) spans x 300..500, y 140..180. + let p = screen(harness.state(), 400.0, 160.0); + harness.hover_at(p); + harness.step(); + click(&mut harness, p); + click(&mut harness, p); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().expect("editing the cell"); + assert_eq!((ts.shape, ts.cell), (id, Some((1, 1)))); + assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range"); + let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret"); + for q in [top, bottom] { + assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); + } + // A click in another cell moves the caret there. + let p = screen(harness.state(), 110.0, 120.0); + click(&mut harness, p); + harness.run_steps(1); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().unwrap(); + assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0))); + let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); + assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); + } + + /// Table borders show a resize cursor and drag to resize columns. + #[test] + fn table_borders_resize_with_the_pointer() { + let (mut harness, id) = app_with_table(); + let p = screen(harness.state(), 300.0, 120.0); + harness.hover_at(p); + harness.step(); + harness.step(); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn); + let q = screen(harness.state(), 340.0, 120.0); + harness.drag_at(p); + harness.step(); + for k in 1..=4 { + harness.hover_at(p + (q - p) * k as f32 / 4.0); + harness.step(); + } + harness.drop_at(q); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") }; + assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); + } + #[test] fn grid_step_follows_spacing_and_rejects_unusable_values() { let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4829e004..30fddc94 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1521,6 +1521,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("JPEG image (current slide)", "jpeg"), ("PowerPoint Presentation (.pptx)", "pptx"), ("Outline (.txt)", "outline"), + ("Animated GIF (.gif)", "gif"), ] { if ui.radio(fmt == f, l).clicked() { d.fields.insert("fmt".into(), f.into()); @@ -1564,6 +1565,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } + let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; + if let Err(e) = &gif_params { + ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); + } let pdf_params = { let (layout, per) = match pdf_layout.as_str() { "notes" => ("notes", 0), @@ -1574,7 +1579,19 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }; let (ok, cancel) = buttons(ui, "Export"); if ok { - let extra = if fmt == "pdf" { pdf_params } else { json!({}) }; + let gif_params = match gif_params { + Ok(v) => v, + // Keep the dialog open so the range or width can be corrected. + Err(e) => { + app.set_status(e); + return false; + } + }; + let extra = match fmt.as_str() { + "pdf" => pdf_params, + "gif" => gif_params, + _ => json!({}), + }; app.export_as(&fmt, extra); } ok || cancel @@ -3205,6 +3222,64 @@ mod size_position_tests; #[path = "photo_album_tests.rs"] mod photo_album_tests; +/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden +/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. +fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result { + 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; @@ -3247,6 +3322,68 @@ mod tests { h } + /// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171). + #[test] + fn export_dialog_writes_an_animated_gif() { + use std::cell::RefCell; + + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.gif"); + let picked = path.to_string_lossy().to_string(); + let suggested = std::rc::Rc::new(RefCell::new(String::new())); + let seen = suggested.clone(); + let services = crate::Services { + pick_save: Some(Box::new(move |s: &str| { + *seen.borrow_mut() = s.to_string(); + Some(picked.clone()) + })), + ..Default::default() + }; + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + harness.get_by_label("Animated GIF (.gif)").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif")); + harness.get_by_label("Seconds per slide:"); + harness.get_by_label("Loop forever").click_accesskit(); + harness.run_steps(2); + // A range outside the deck keeps the dialog open with a message, and nothing is written. + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status); + assert!(!path.exists()); + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow()); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default"); + assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn no_link_opens_as_a_new_web_address() { let seed = seed_from(CurrentLink::None, &[]); 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/lib.rs b/crates/ui-egui/src/lib.rs index cb8f12ed..337bf502 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod fillui; pub mod icons; pub mod media; pub mod menus; +pub mod notes; pub mod panes; mod preview; pub mod qat; @@ -35,6 +36,7 @@ pub mod toast; pub mod uifonts; pub mod widgets; +use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; use deckcraft_engine::{Mods, Session, Target, UiRequest}; @@ -83,6 +85,8 @@ enum FilePurpose { Picture, Audio, Video, + /// Change Picture: the picture replaces the selected one (`picture.change`). + ChangePicture, /// A drop, or any other purpose: decide from the name and bytes. Infer, } @@ -182,12 +186,16 @@ fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'stati FilePurpose::Picture => Some("insert.picture"), FilePurpose::Audio => Some("insert.audio"), FilePurpose::Video => Some("insert.video"), + FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), FilePurpose::Open => open_command(name), FilePurpose::Infer => infer_command(name, bytes), }; cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) } +/// How long a transient status message stays visible. +const STATUS_LINGER_MS: f64 = 6000.0; + /// Platform services injected by the host (desktop or web). #[derive(Default)] pub struct Services { @@ -389,6 +397,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), + ("app.changePictureDialog", "Change Picture…", None, "{}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), @@ -476,7 +485,7 @@ pub struct SlideApp { pub last_recovery: f64, /// Quit was confirmed (changes saved or discarded): let the window close. pub quit_confirmed: bool, - pub(crate) notes_buf: (u64, usize, String), + pub(crate) notes_buf: (u64, usize, notes::Draft), /// Faces borrowed from the font database for text the interface fonts can't draw (#101). pub ui_fonts: uifonts::UiFonts, /// Thumbnail pane drag (slide index, current drop index). @@ -489,6 +498,9 @@ pub struct SlideApp { pub sorter: sorter::State, /// The slide thumbnail pane was the last thing clicked, so ⌘A selects slides, not shapes. pub(crate) slides_focused: bool, + /// Which recent paths exist on disk, probed once per path per session (a dead network share + /// must not stall every frame). Not persisted. + recent_checked: HashMap, /// A trackpad twist is in progress (one undo step when it ends). twisting: bool, /// Next cancellation identity for a browser pick or a dropped file. @@ -549,13 +561,14 @@ impl SlideApp { last_time: 0.0, last_recovery: 0.0, quit_confirmed: false, - notes_buf: (0, usize::MAX, String::new()), + notes_buf: (0, usize::MAX, notes::Draft::default()), ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), doc_window: None, sorter: sorter::State::default(), slides_focused: false, + recent_checked: HashMap::new(), twisting: false, next_file_id: 1, pending_files: Vec::new(), @@ -577,6 +590,56 @@ impl SlideApp { self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() }); } + /// The transient status message while it is fresh; clears it once stale. Both the status bar + /// and the start screen draw it — the start screen has no status bar, and errors raised there + /// (a missing recent file, a failed sample) used to vanish (issue #172). + pub fn fresh_status(&mut self) -> Option { + 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 { @@ -671,6 +734,7 @@ impl SlideApp { "pptx" => ("pptx", "pptx", false), "outline" | "txt" => ("txt", "outline", false), "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), other => { self.set_status(format!("DeckCraft can't export “{other}”")); return false; @@ -700,6 +764,7 @@ impl SlideApp { Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }) } "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), other => deckcraft_engine::cmd::file::save_bytes(&doc, other), }; self.download(&suggested, bytes) @@ -932,6 +997,7 @@ impl SlideApp { } self.pick_and_run("picture", "smartArt.picture", p); } + "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), @@ -1104,6 +1170,8 @@ impl SlideApp { pub fn open_path(&mut self, path: &str) -> Result<(), String> { self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(RECENT_MAX); @@ -1145,6 +1213,12 @@ impl SlideApp { } } else if self.services.open_async.is_some() { let id = self.begin_external_file(purpose); + // Change Picture: the picture that arrives replaces the selected one (#115). + if cmd == "picture.change" + && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) + { + r.purpose = FilePurpose::ChangePicture; + } if let Some(f) = self.services.open_async.as_mut() { f(purpose, id); } @@ -1887,6 +1961,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; + use egui_kittest::kittest::Queryable; fn shape_rot(app: &SlideApp) -> f64 { let d = app.session.active().unwrap(); @@ -2003,6 +2078,61 @@ mod tests { assert!(harness.state().toast.is_none()); } + /// #115: Change Picture replaced nothing — it inserted a second picture. + #[test] + fn change_picture_replaces_the_selected_picture() { + let svg = |c: &str| { + format!("").into_bytes() + }; + let media = |app: &SlideApp| { + let d = app.session.active().unwrap(); + let pics: Vec<_> = d.doc.slides[0] + .shapes + .iter() + .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) + .collect(); + assert_eq!(pics.len(), 1, "one picture, replaced in place"); + pics[0] + }; + let insert = |app: &mut SlideApp| { + let id = + app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() + ["id"] + .clone(); + app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + media(app) + }; + + // Desktop: the file dialog returns a path. + let blue = svg("blue"); + let services = Services { + pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), + read: Some(Box::new(move |_: &str| Ok(blue.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + assert_ne!(media(&app), before); + + // Web: the browser picker delivers the file through the inbox later. + let inbox = Inbox::default(); + let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), + inbox: Some(inbox.clone()), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + let id = started.lock().unwrap()[0]; + inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") })); + app.drain_inbox(); + assert_ne!(media(&app), before); + } + /// #17: Home created a new blank presentation instead of going to the start screen. #[test] fn home_shows_the_start_screen_over_the_open_presentation() { @@ -2479,6 +2609,177 @@ mod tests { assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); } + fn harness() -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::new(), Services::default()), + ) + } + + /// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws + /// the open error right there (the status bar is hidden while the backstage is open, so the + /// message used to vanish). + #[test] + fn missing_recent_is_marked_and_its_error_shows() { + let mut harness = harness(); + let missing = "/deckcraft-tests/172/missing.deckcraft"; + harness.state_mut().ui.recent = vec![missing.to_string()]; + harness.run_steps(2); + // The row is labelled with the file's name. Clicking it raises the open error, and the + // drawn message is a second node that mentions the name (the OS text after it varies). + harness.get_by_label("missing.deckcraft").click(); + harness.run_steps(2); + assert!(harness.state().toast.is_some(), "the open error was raised"); + assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); + } + + /// #172: a Recent entry can be removed from the list. + #[test] + fn recent_entry_can_be_removed() { + let mut harness = harness(); + harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; + harness.run_steps(2); + let removes = harness.get_all_by_label("Remove from Recent").collect::>(); + 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| { 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 e2a5a78e..2c69308c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -784,6 +784,10 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } }); }); + let mut tr = ((1.0 - color.alpha()) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } } Some(Fill::Gradient(g)) => { current = Some(json!({"gradient": crate::fillui::gradient_json(g)})); @@ -813,7 +817,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } } - Some(Fill::Picture(_)) => { + Some(Fill::Picture(pf)) => { if ui.button("Insert picture from file…").clicked() && let Some(pick) = app.services.pick_open.as_mut() && let Some(path) = pick("picture") @@ -821,6 +825,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { { let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); } + picture_background_options(app, ui, pf); } _ => {} } @@ -831,12 +836,15 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.hideBackgroundGraphics", json!({"hide": h})); } ui.add_space(8.0); + let picture = matches!(fill, Some(Fill::Picture(_))); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked() - && let Some(Value::Object(mut o)) = current.clone() - { - o.insert("all".into(), json!(true)); - run(app, "design.background", Value::Object(o)); + if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if let Some(Value::Object(mut o)) = current.clone() { + o.insert("all".into(), json!(true)); + run(app, "design.background", Value::Object(o)); + } else if picture { + run(app, "design.background", json!({"copy": true})); + } } if ui.button("Reset Background").clicked() { run(app, "design.background", json!({"reset": true})); @@ -844,6 +852,86 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } +fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + let alpha = pf.alpha.unwrap_or(1.0); + let mut tr = ((1.0 - alpha) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "design.background", json!({"pictureProps": {"tile": tile}})); + } + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; + for (label, id, value, suffix, scale) in [ + ("Offset X", "bg-ox", *tx, " pt", 1.0), + ("Offset Y", "bg-oy", *ty, " pt", 1.0), + ("Scale X", "bg-sx", *sx * 100.0, "%", 0.01), + ("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01), + ] { + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); + if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { + let key = match id { + "bg-ox" => "offsetX", + "bg-oy" => "offsetY", + "bg-sx" => "scaleX", + _ => "scaleY", + }; + run(app, "design.background", json!({"pictureProps": {key: v * scale}})); + } + }); + } + let align_name = match align.as_str() { + "t" => "Top", + "tr" => "Top Right", + "l" => "Left", + "ctr" => "Center", + "r" => "Right", + "bl" => "Bottom Left", + "b" => "Bottom", + "br" => "Bottom Right", + _ => "Top Left", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); + egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [ + ("tl", "Top Left"), + ("t", "Top"), + ("tr", "Top Right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom Left"), + ("b", "Bottom"), + ("br", "Bottom Right"), + ] { + if ui.selectable_label(align == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"align": k}})); + } + } + }); + }); + let mirror_name = match flip.as_str() { + "x" => "Horizontal", + "y" => "Vertical", + "xy" => "Both", + _ => "None", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); + egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { + if ui.selectable_label(flip == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"mirror": k}})); + } + } + }); + }); +} + fn animation(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -1176,7 +1264,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { let key_doc = (st.uid, st.revision); let w = ui.available_width() - 8.0; let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp).max(16.0) as u32; + let px = (w * ppp).round().max(16.0) as u32; let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx); let themes = deckcraft_model::theme::builtin_themes(); let cache_id = egui::Id::new("design-idea-thumbs"); @@ -1194,7 +1282,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = crate::textures::upload(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false)); + let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false)); cache.tex.push((name, tex, px, h)); }; for (id, _) in &ideas { @@ -1215,9 +1303,12 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool { let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false }; let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click()); - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + // On the physical pixel grid, one texel per device pixel (#184). + let at = crate::textures::device_pixel_rect(r, ppp); + let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp)); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( - r, + at, CornerRadius::same(2), Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Outside, @@ -1275,6 +1366,10 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec #[path = "smartart_tests.rs"] mod smartart_tests; +#[cfg(test)] +#[path = "background_tests.rs"] +mod background_tests; + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2ab6dfcf..b33b9dc4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -395,8 +395,9 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = crate::textures::upload(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); - ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); + let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke( @@ -2638,7 +2639,7 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } }); rows(ui, |ui| { - small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({})); + small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); }); }); 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 7bacd91a..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() @@ -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/textures.rs b/crates/ui-egui/src/textures.rs index e1f8b1f5..dae67b44 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 { @@ -98,6 +98,39 @@ impl Textures { } } +/// Snap `rect` onto the physical pixel grid. +/// +/// The editor draws the slide as a texture of device pixels. A rect that starts or ends +/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two +/// texels and small text looks soft. On a Retina display that offset is half a point, which +/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so +/// the same blend is not visible there (#184). +pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect { + if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() { + return rect; + } + let snap = |v: f32| { + let p = v * ppp; + if p.is_finite() { p.round() / ppp } else { v } + }; + let min = egui::pos2(snap(rect.min.x), snap(rect.min.y)); + let step = 1.0 / ppp; + let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step)); + egui::Rect::from_min_max(min, max) +} + +/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a +/// fraction of a pixel from blurring the glyph; minification still blends when many texels +/// share a screen pixel. +pub fn editor_texture_options() -> TextureOptions { + TextureOptions { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + } +} + pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { let w = img.width as usize; let h = img.height as usize; @@ -175,6 +208,17 @@ pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) { tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR); } +/// [`upload`] for editor slide pictures, drawn on the device pixel grid with +/// [`editor_texture_options`] (#184). +pub fn upload_editor(ctx: &egui::Context, name: impl Into, 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() } @@ -283,10 +327,10 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - replace(ctx, &mut e.tex, ci); + replace_editor(ctx, &mut e.tex, ci); e.tex } - None => upload(ctx, "slide-canvas", ci), + None => upload_editor(ctx, "slide-canvas", ci), }; self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); Some((tex, region)) @@ -335,7 +379,7 @@ impl Textures { let (tw, th) = fit_size((width_px, h), max_side(ctx)); let scale = tw as f64 / p.slide_size.width.max(1.0); let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() }); - let tex = upload(ctx, format!("thumb-{index}"), to_color_image(&img, false)); + let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false)); // Drop the old entry for this slide address; entries for slides that no longer exist age out. let region = key.want; self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock }); @@ -350,6 +394,39 @@ impl Textures { } } +#[cfg(test)] +mod tests { + use super::device_pixel_rect; + use egui::{Rect, pos2, vec2}; + + fn on_grid(v: f32, ppp: f32) -> bool { + let px = v * ppp; + (px - px.round()).abs() < 1e-3 + } + + #[test] + fn a_retina_slide_rect_lands_on_device_pixels() { + let ppp = 2.0; + let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4)); + let s = device_pixel_rect(r, ppp); + assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp)); + assert!(s.width() > 0.0 && s.height() > 0.0); + // One texture pixel per device pixel: the snapped size is an integer pixel count. + let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32); + assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3); + assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3); + let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0)); + assert_eq!(device_pixel_rect(aligned, ppp), aligned); + } + + #[test] + fn a_bad_scale_does_not_move_the_rect() { + let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0)); + assert_eq!(device_pixel_rect(r, 0.0), r); + assert_eq!(device_pixel_rect(r, f32::NAN), r); + } +} + /// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits. pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region { fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) { diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 82e10d4a..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,10 +276,11 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 }; let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); - let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); let ppp = ui.ctx().pixels_per_point(); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)), ppp); + let wpx = ((img.width() * ppp).round() as u32).max(1); let px = crate::textures::fit_size( - (((tw * ppp) as u32).max(1), ((tw * ppp) as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + (wpx, (wpx as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), crate::textures::max_side(ui.ctx()), ); let pic = deckcraft_render::render_layout( @@ -293,7 +294,7 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { ..Default::default() }, ); - let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false)); + let tex = crate::textures::upload_editor(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false)); ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let selected = match (target, li) { (Target::Master { master }, None) => master == mi, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aaca4535..b7b6f013 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | Method | Params | What it does | |---|---|---| -| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) | +| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) | @@ -37,7 +37,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | | `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude -and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell. +and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell. 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 e9470bc7..5b329773 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -25,6 +25,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture options are pane controls, not a new dialog; dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview plays on the editor canvas; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page in the editor and the show; pinch-zoom remains; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -199,11 +200,13 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** handouts and speaker notes are printed in many workplaces. - **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.** -### No video or animated GIF export -- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF. +### No video export; the animated GIF has no transitions or builds +- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes + an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF + doesn't animate transitions or build animations. - **Impact:** sharing a talk as a video is a common request. -- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part, - FilmCraft has one to copy). +- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for + transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy). ### Embedded MicroType Express font subsets aren't drawn - **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in. @@ -391,7 +394,9 @@ Detail in [ui-parity.md](ui-parity.md). - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Action Settings, Print, Insert Object…). 8–14 h. - **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print, - Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h. + Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. + Format Background picture options (transparency, tile, offset, scale, alignment, mirror, and Apply + to All copying a picture) are pane controls on `design.background`, not another dialog. 8–14 h. - **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline, colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. @@ -495,6 +500,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | trivial | Command palette skips commands that need a value, so Export opens the dialog (#79) | | 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 | | 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 | +| 2026-10-14 | minor | Format Background picture and solid options are in the existing pane. Dialogs stay 27 | | 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) | | 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words | | 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web | @@ -513,5 +519,6 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | +| 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1ba069ec..c803da55 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -16,8 +16,8 @@ because it mostly follows from hardware use. | Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback | | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | -| High-DPI / Retina | yes | yes | yes | yes | yes | | Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same | +| High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | | Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a | | Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no | @@ -80,6 +80,7 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size | +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | | 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged | | 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) | diff --git a/docs/mcp.md b/docs/mcp.md index 07284e70..5f6c5756 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -20,7 +20,7 @@ deck. | `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered | | `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command | | `batch` | Run several commands in order and stop at the first error | -| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline | +| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline, animated GIF (size, seconds per slide, loop, slides) | | `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) | | `render_slide` | A slide as an image the model can look at | | `add_slide`, `go_to_slide` | Slides by layout | @@ -35,5 +35,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` +(GIF). `deckcraft-cli describe COMMAND` prints any command's parameters. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 06ff6985..d3497c6f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # PowerPoint feature checklist -> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` **Weighted breadth parity: 82%** over 193 features. @@ -85,7 +85,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P | | Files | Export slides as PNG/JPEG | P0 | D | | Files | Export outline / RTF | P2 | P | -| Files | Export video (MP4) / animated GIF | P2 | M | +| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | | Files | Reuse slides from another file | P1 | M | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 815d06b3..1bcff00a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -17,6 +17,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (Preview in editor re-scored done; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -251,7 +252,9 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc latin faces (one scheme on that master, not a library of saved font schemes, and not East Asian or complex-script faces). effect schemes (Design ▸ Effects: DeckCraft, Soft, Crisp, Lift, Glow, Plain), - backgrounds (solid, gradient, picture, pattern), slide size presets. + backgrounds (solid, gradient, picture, pattern; a solid fill's transparency; picture + transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture), + slide size presets. - **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop, corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites, @@ -353,6 +356,8 @@ scored done (16 open issues). | 2026-10-13 | trivial | Essentials discoverability note: the command palette's Export failure (#79) is fixed; the ×0.95 discount stays for the menu bar and start screen | | 2026-10-14 | minor | Design ▸ Effects noted under what works today. Percentages stay 79% / ~60% / ~55%. The d556ff9 snapshot and the menu measurement stay at 226 commands; the live registry is 227 | | 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages unchanged | +| 2026-10-11 | minor | Animated GIF export (#171) moves the checklist's video/GIF row from missing to partial: P2 breadth 36% → 37%; overall breadth (79%), dimension scores and stage unchanged | +| 2026-10-14 | minor | Format Background picture and solid options noted under what works today. Percentages, the 226-command count and the d556ff9 dialog snapshot stay as they are | | 2026-10-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% | | 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged | | 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 77eefa4c..0bd84709 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -33,6 +33,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -98,8 +99,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D | -| Format Background pane | yes | yes | | Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | +| Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes: every slide, with Previous and Next on the Review tab | @@ -142,6 +143,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time | +| Drag the frame of a text box being edited to move it | yes | yes | +| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | @@ -195,8 +198,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93). Colours, spacing, ribbon card and tab geometry follow the observations in `plan/powerpoint/01-observed-ui.md`; the ribbon and menu icons are original (~240, drawn in code) and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0) -on the slide as shapes. No pixel comparison against PowerPoint screenshots has been run since -2026-10-07. Cross-app icon consistency is open (#73, #56). +on the slide as shapes. The editing canvas, thumbnails and layout previews are drawn on the +physical pixel grid, so a Retina display does not blur them between pixels (#184). No pixel +comparison against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon +consistency is open (#73, #56). ## Estimates @@ -246,6 +251,10 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 | | 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout | | 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip | +| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint | +| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize | +| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 | +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps | | 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas | | 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss |