diff --git a/ROADMAP.md b/ROADMAP.md index e69c24d2..b9aacce8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,10 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (animation preview on the canvas; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Shape fill transparency follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: web file picker keeps its document and slide, #414; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Font dialog; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Paragraph dialog; previous: trivial, Font dialog; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -222,6 +226,10 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged | | 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency | | 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) | +| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step. Breadth stays 79% | +| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | +| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | +| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -292,6 +300,9 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% | | 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection | | 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) | +| 2026-10-14 | trivial | Progress log: Paragraph dialog | +| 2026-10-14 | trivial | Progress log: Font dialog | +| 2026-10-13 | trivial | Progress log: Grid Options dialog | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/engine/data/thesaurus.txt b/crates/engine/data/thesaurus.txt new file mode 100644 index 00000000..1916eb46 --- /dev/null +++ b/crates/engine/data/thesaurus.txt @@ -0,0 +1,264 @@ +# DeckCraft's built-in thesaurus: headword|synonym, synonym, ... +# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line; +# a headword may appear on several lines (one per sense). +ability|capability, capacity, skill, competence, talent +able|capable, competent, qualified, skilled +accept|agree to, approve, adopt, embrace +accurate|precise, exact, correct, right +achieve|accomplish, attain, reach, realize, deliver +achievement|accomplishment, success, attainment, milestone +add|include, append, attach, insert +advantage|benefit, edge, strength, asset, upside +affect|influence, impact, change, alter +aim|goal, objective, target, purpose, intent +allow|permit, enable, let, authorize +analyze|examine, study, assess, evaluate, review +answer|reply, response, solution +approach|method, strategy, way, technique, plan +area|region, field, domain, zone, sector +arrange|organize, order, sort, plan +ask|request, inquire, query, question +assess|evaluate, gauge, judge, review, appraise +assist|help, aid, support, back +attempt|try, effort, endeavor, bid +bad|poor, weak, inferior, unfavorable, negative +basic|fundamental, essential, core, elementary, simple +begin|start, commence, launch, initiate, open +benefit|advantage, gain, value, payoff, upside +best|finest, top, leading, optimal, premier +big|large, major, substantial, significant, sizable +boost|increase, raise, lift, improve, strengthen +brief|short, concise, succinct, quick +bright|vivid, brilliant, luminous, clever +build|construct, create, develop, assemble, establish +business|company, firm, enterprise, organization, venture +buy|purchase, acquire, obtain +careful|cautious, thorough, meticulous, attentive +cause|reason, source, origin, driver +change|alter, modify, adjust, revise, transform +change|shift, transition, adjustment, revision +cheap|inexpensive, affordable, low-cost, economical +check|verify, confirm, examine, inspect, review +choice|option, alternative, selection, pick +choose|select, pick, opt for, decide on +clear|plain, obvious, evident, transparent, lucid +client|customer, buyer, account, patron +close|near, nearby, adjacent +close|end, finish, conclude, shut +common|frequent, usual, typical, widespread, shared +company|business, firm, organization, enterprise, corporation +complete|finish, conclude, finalize, accomplish +complete|whole, full, entire, total +complex|complicated, intricate, involved, elaborate +concern|worry, issue, problem, risk +consider|weigh, contemplate, evaluate, think about +continue|proceed, carry on, keep going, persist, maintain +cost|price, expense, charge, outlay +create|make, build, produce, design, generate +critical|crucial, vital, essential, key, decisive +current|present, existing, ongoing, latest, up-to-date +customer|client, buyer, consumer, user +data|information, figures, facts, statistics +decide|determine, choose, resolve, settle +decrease|reduce, lower, cut, decline, drop +define|specify, describe, explain, outline +deliver|provide, supply, bring, hand over, ship +demonstrate|show, prove, illustrate, display +describe|explain, outline, portray, depict +design|plan, layout, blueprint, pattern +design|create, plan, devise, draft +develop|build, create, grow, expand, evolve +difference|distinction, contrast, gap, variation +different|distinct, various, diverse, unlike +difficult|hard, challenging, tough, demanding +discuss|talk about, debate, examine, review +easy|simple, straightforward, effortless, painless +effect|result, impact, outcome, consequence +effective|successful, efficient, productive, powerful +effort|work, attempt, endeavor, exertion +end|finish, conclusion, close, completion +enough|sufficient, adequate, ample +ensure|guarantee, make sure, secure, confirm +error|mistake, fault, flaw, bug, slip +essential|vital, crucial, key, necessary, fundamental +establish|set up, found, create, institute +estimate|forecast, projection, approximation, guess +evaluate|assess, appraise, judge, rate, review +event|occasion, function, happening, incident +example|instance, case, illustration, sample +excellent|outstanding, superb, exceptional, first-rate +expand|grow, extend, enlarge, broaden, scale +expect|anticipate, foresee, predict, await +explain|clarify, describe, illustrate, account for +fail|fall short, miss, flop, break down +fast|quick, rapid, swift, speedy +feature|characteristic, attribute, capability, function +final|last, closing, concluding, ultimate +find|discover, locate, identify, uncover +finish|complete, end, conclude, wrap up +focus|concentrate, center, target, emphasize +focus|emphasis, priority, attention, core +future|upcoming, coming, forthcoming, prospective +gain|increase, rise, improvement, profit +get|obtain, acquire, receive, gain +give|provide, offer, supply, grant +goal|aim, objective, target, purpose, ambition +good|fine, great, strong, solid, positive +great|excellent, outstanding, terrific, remarkable +grow|increase, expand, rise, develop, scale +growth|expansion, increase, development, rise +help|assist, aid, support, enable +help|assistance, aid, support, guidance +high|elevated, tall, top, strong +idea|concept, notion, thought, proposal +identify|recognize, pinpoint, detect, determine +impact|effect, influence, consequence, result +important|significant, key, major, crucial, vital +improve|enhance, upgrade, refine, strengthen, boost +include|contain, cover, incorporate, comprise +increase|raise, boost, grow, expand, lift +increase|rise, growth, gain, jump +information|data, details, facts, knowledge +innovative|inventive, creative, original, novel, pioneering +issue|problem, concern, matter, question +job|task, assignment, role, position +keep|retain, maintain, preserve, hold +key|main, central, critical, essential, principal +large|big, sizable, substantial, major, extensive +last|final, latest, previous, closing +launch|start, introduce, roll out, release, unveil +lead|guide, direct, head, steer, run +learn|discover, find out, master, study +less|fewer, smaller, reduced, lower +little|small, minor, slight, modest +look|appear, seem +look|view, glance, examine, inspect +low|minimal, modest, small, reduced +main|primary, chief, principal, central, key +maintain|keep, preserve, sustain, uphold +major|significant, important, key, principal, big +make|create, build, produce, generate +manage|run, handle, oversee, direct, lead +market|industry, sector, segment, marketplace +measure|gauge, assess, quantify, track +meet|satisfy, fulfil, reach, achieve +meeting|session, conference, discussion, review +method|approach, technique, process, procedure, way +model|framework, template, pattern, design +more|additional, extra, further, added +move|shift, transfer, relocate, transition +need|requirement, demand, necessity +need|require, call for, demand, want +new|fresh, recent, novel, modern, latest +next|following, upcoming, subsequent +number|figure, count, total, quantity, amount +objective|goal, aim, target, purpose +offer|provide, propose, extend, present +old|previous, former, legacy, outdated, existing +open|start, launch, begin, unveil +opportunity|opening, chance, prospect, possibility +option|choice, alternative, possibility, route +organize|arrange, structure, coordinate, order +outcome|result, consequence, effect, end result +overview|summary, outline, synopsis, recap +part|portion, piece, segment, section, component +partner|ally, collaborator, associate, affiliate +people|staff, team, individuals, personnel +plan|strategy, scheme, roadmap, proposal, program +plan|organize, arrange, schedule, prepare +point|aspect, detail, issue, argument +popular|well-liked, widespread, in demand, favored +possible|feasible, achievable, viable, potential +present|show, display, introduce, demonstrate +present|current, existing, ongoing +price|cost, rate, charge, fee +priority|focus, concern, precedence, goal +problem|issue, challenge, difficulty, obstacle +process|procedure, method, workflow, system +produce|make, create, generate, manufacture, yield +product|offering, item, solution, good +profit|earnings, return, gain, margin +progress|advance, headway, development, improvement +project|initiative, program, undertaking, venture +propose|suggest, recommend, put forward, offer +provide|give, supply, deliver, offer +purpose|aim, goal, objective, intent, reason +quality|standard, caliber, excellence, grade +question|query, issue, inquiry +quick|fast, rapid, speedy, swift, prompt +rapid|fast, quick, swift, speedy +reach|achieve, attain, hit, arrive at +reason|cause, rationale, motive, basis +recent|latest, new, current, fresh +recommend|suggest, advise, propose, advocate +reduce|cut, lower, decrease, lessen, trim +region|area, territory, zone, market +relevant|pertinent, applicable, related, appropriate +remove|delete, eliminate, take out, drop +replace|substitute, swap, supersede, change +report|account, summary, review, statement +require|need, demand, call for, necessitate +research|study, investigation, analysis, inquiry +resource|asset, supply, means, capacity +result|outcome, effect, consequence, finding +review|assessment, evaluation, examination, analysis +review|examine, assess, evaluate, check, revisit +revenue|income, sales, earnings, turnover +risk|danger, threat, exposure, hazard +role|part, function, position, responsibility +run|operate, manage, conduct, execute +safe|secure, protected, reliable +same|identical, equal, matching, equivalent +save|keep, store, preserve, conserve +see|view, observe, notice, spot +sell|market, offer, trade, retail +service|support, assistance, offering +share|portion, part, stake, percentage +share|distribute, spread, communicate, pass on +show|demonstrate, display, present, reveal, illustrate +significant|important, notable, considerable, major, meaningful +similar|alike, comparable, related, equivalent +simple|easy, straightforward, plain, basic +size|scale, extent, magnitude, dimensions +small|little, minor, compact, modest +solution|answer, fix, remedy, resolution +solve|resolve, fix, settle, work out +specific|particular, precise, exact, distinct +start|begin, launch, commence, kick off, initiate +step|stage, phase, measure, action +strategy|plan, approach, policy, roadmap, tactic +strong|robust, solid, powerful, sturdy +structure|framework, organization, layout, arrangement +success|achievement, accomplishment, win, triumph +successful|effective, thriving, prosperous, winning +suggest|propose, recommend, advise, imply +summary|overview, recap, synopsis, digest +support|help, back, assist, sustain +support|help, assistance, backing, aid +sure|certain, confident, positive, definite +target|goal, objective, aim +task|job, assignment, duty, activity +team|group, crew, staff, squad, unit +test|trial, check, experiment, evaluation +test|try, check, evaluate, verify +think|believe, consider, reckon, suppose +time|period, duration, while, moment +tool|instrument, utility, device, aid +total|sum, whole, aggregate, overall +trend|pattern, tendency, movement, direction +understand|grasp, comprehend, follow, realize +unique|distinctive, singular, one-of-a-kind, unmatched +update|revise, refresh, upgrade, modernize +update|revision, change, upgrade, news +use|employ, apply, utilize, leverage +useful|helpful, practical, handy, valuable +value|worth, benefit, merit, importance +various|diverse, assorted, different, several +view|opinion, perspective, outlook, position +vision|outlook, aspiration, goal, dream +way|method, approach, manner, means +weak|feeble, fragile, poor, limited +well|effectively, successfully, nicely, properly +work|labor, effort, activity, job +work|function, operate, run, succeed +year|annum, twelve months diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index a7109665..c819ab3a 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -9,6 +9,18 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!( + "guide.add", + "Add Guide", + ["View", "Show", "Guides"], + None, + "{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none", + has_doc, + guide_add + ), + cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move), + cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete), + cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list), cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)), cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), @@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// Guides are kept on the slide; a file can hold at most this many. +const MAX_GUIDES: usize = 200; + +fn guide_limit(s: &Session, vertical: bool) -> Result { + let size = s.doc()?.doc.slide_size; + Ok(if vertical { size.width } else { size.height }) +} + +fn guide_add(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "defaults", false) { + if !s.doc()?.doc.guides.is_empty() { + return Ok(json!({"added": 0})); + } + let size = s.doc()?.doc.slide_size; + return s.edit(|doc, _| { + doc.guides = vec![ + deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 }, + deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 }, + ]; + Ok(json!({"added": 2})) + }); + } + let vertical = bool_or(p, "vertical", true); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit); + if s.doc()?.doc.guides.len() >= MAX_GUIDES { + return Err(bad("guide.add", "too many guides")); + } + s.edit(|doc, _| { + doc.guides.push(deckcraft_model::DrawGuide { vertical, pos }); + Ok(json!({"index": doc.guides.len() - 1})) + }) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?; + if i >= s.doc()?.doc.guides.len() { + return Err(bad(cmd, "no such guide")); + } + Ok(i) +} + +fn guide_move(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.move")?; + let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit); + s.edit(|doc, _| { + if let Some(g) = doc.guides.get_mut(i) { + g.pos = pos; + } + Ok(json!({"index": i, "pos": pos})) + }) +} + +fn guide_delete(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.delete")?; + s.edit(|doc, _| { + if i < doc.guides.len() { + doc.guides.remove(i); + } + ok() + }) +} + +fn guide_list(s: &mut Session, _p: &Value) -> Result { + Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default()) +} + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 6e2434c4..687b1639 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -67,7 +67,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool { || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) } -fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { +pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { deckcraft_model::walk(shapes, &mut |s, _| { match &s.kind { deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media), diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 00000000..4991cd1f --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,240 @@ +//! Home › Editing: Find and Replace across slide text, table cells and speaker notes. + +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.find", + "Find", + ["Home", "Editing"], + None, + "{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)", + has_doc, + find + ), + cmd!( + "text.replace", + "Replace", + ["Home", "Editing"], + None, + "{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}", + has_doc, + replace + ), + ] +} + +/// Where a text body lives on a slide. +#[derive(Clone, Copy, PartialEq, Debug)] +struct Loc { + shape: Option, + cell: Option<(usize, usize)>, +} + +/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes. +fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) { + fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) { + if depth > 64 { + return; + } + for sh in shapes { + let id = Some(u64::from(sh.id.0)); + if let Some(t) = &mut sh.text { + f(Loc { shape: id, cell: None }, t); + } + match &mut sh.kind { + ShapeKind::Table(t) => { + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text); + } + } + } + ShapeKind::Group { children, .. } => rec(children, depth + 1, f), + _ => {} + } + } + } + rec(&mut slide.shapes, 0, f); + f(Loc { shape: None, cell: None }, &mut slide.notes); +} + +struct Opts { + pat: Vec, + case: bool, + whole: bool, +} + +impl Opts { + fn from(p: &Value, cmd: &str) -> Result { + let pat: Vec = str_param(p, "text").unwrap_or("").chars().collect(); + if pat.is_empty() { + return Err(bad(cmd, "missing `text`")); + } + Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) }) + } + fn eq(&self, a: char, b: char) -> bool { + a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase())) + } + /// Non-overlapping matches as char ranges. + fn matches(&self, text: &[char]) -> Vec<(usize, usize)> { + let n = self.pat.len(); + let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_'); + let mut out = vec![]; + let mut i = 0; + while i + n <= text.len() { + let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b)) + && (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n)))); + if hit { + out.push((i, i + n)); + i += n; + } else { + i += 1; + } + } + out + } +} + +fn para_chars(p: &Paragraph) -> Vec { + p.runs.iter().flat_map(|r| r.text.chars()).collect() +} + +/// Matches that lie wholly in plain text runs (never inside a field, equation or break). +fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut off = 0; + for r in &p.runs { + let n = r.text.chars().count(); + spans.push((off, off + n, matches!(r.kind, RunKind::Text))); + off += n; + } + o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect() +} + +/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting. +fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { + let mut off = 0; + let mut placed = false; + for r in &mut p.runs { + let chars: Vec = r.text.chars().collect(); + let (s, e) = (off, off + chars.len()); + off = e; + if e <= a || s >= b { + continue; + } + let keep_head: String = chars[..a.saturating_sub(s)].iter().collect(); + let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect(); + r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") }; + placed = true; + } + if p.runs.len() > 1 { + p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text))); + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.find")?; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + let mut sl = (**sl).clone(); + bodies(&mut sl, &mut |loc, t| { + for (pi, para) in t.paragraphs.iter().enumerate() { + for (a, b) in para_matches(para, &o) { + out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b})); + } + } + }); + } + Ok(Value::Array(out)) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.replace")?; + let with = str_param(p, "with").unwrap_or("").to_string(); + let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> { + let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`"))); + let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))); + Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?)) + }); + let at = at.transpose()?; + let count = s.edit(|doc, _| { + let mut count = 0; + for (i, slp) in doc.slides.iter_mut().enumerate() { + if at.is_some_and(|a| a.0 != i) { + continue; + } + let mut sl = (**slp).clone(); + let mut changed = 0; + bodies(&mut sl, &mut |loc, t| { + if at.is_some_and(|a| a.1 != loc) { + return; + } + for (pi, para) in t.paragraphs.iter_mut().enumerate() { + if at.is_some_and(|a| a.2 != pi) { + continue; + } + for (a, b) in para_matches(para, &o).into_iter().rev() { + if at.is_some_and(|x| x.3 != a) { + continue; + } + splice(para, a, b, &with); + changed += 1; + } + } + }); + if changed > 0 { + *slp = Arc::new(sl); + count += changed; + } + } + if count == 0 { + return Err(bad("text.replace", "no matches")); + } + Ok(count) + })?; + Ok(json!({"replaced": count})) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{Run, RunProps}; + + fn opts(t: &str, case: bool, whole: bool) -> Opts { + Opts { pat: t.chars().collect(), case, whole } + } + + #[test] + fn matching_honours_case_and_whole_words() { + let t: Vec = "Cat cat category".chars().collect(); + assert_eq!(opts("cat", false, false).matches(&t).len(), 3); + assert_eq!(opts("cat", true, false).matches(&t).len(), 2); + assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); + } + + #[test] + fn splice_across_runs_keeps_the_first_runs_format() { + let bold = RunProps { bold: Some(true), ..Default::default() }; + let mut p = Paragraph { + runs: vec![ + Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text }, + Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text }, + ], + ..Default::default() + }; + splice(&mut p, 1, 5, "ola"); + assert_eq!(para_chars(&p).iter().collect::(), "Hola world"); + assert_eq!(p.runs[0].text, "Hola"); + assert_eq!(p.runs[0].props, bold); + } +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 806c54ef..515ae069 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -3,7 +3,7 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; -use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TextDir}; +use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape}; use serde_json::{Value, json}; @@ -89,6 +89,15 @@ pub fn specs() -> Vec { spacing ), cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear), + cmd!( + "format.fontDialog", + "Font…", + ["Home", "Font"], + Some("Cmd+T"), + "{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog", + has_text_or_shapes, + font_dialog + ), cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow), cmd!( "format.textOutline", @@ -168,6 +177,25 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler), + cmd!( + "format.paragraphDialog", + "Paragraph…", + ["Home", "Paragraph"], + None, + "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog", + has_text_or_shapes, + paragraph_dialog + ), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs; with none of them, opens the Tabs dialog (pos in pt from the text area's left)", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -391,6 +419,213 @@ fn spacing(s: &mut Session, p: &Value) -> Result { let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); format_run(s, &move |r| r.spacing = Some(v)) } + +/// Character formatting applied together, so the Font dialog is one undo step. +struct FontEdits { + family: Option, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +impl FontEdits { + fn is_empty(&self) -> bool { + self.family.is_none() + && self.size.is_none() + && self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.color.is_none() + && self.strike.is_none() + && self.baseline.is_none() + && self.caps.is_none() + && self.spacing.is_none() + } + + fn write(&self, r: &mut RunProps) { + if let Some(f) = &self.family { + r.font = Some(f.clone()); + } + if let Some(v) = self.size { + r.size = Some(v); + } + if let Some(v) = self.bold { + r.bold = Some(v); + } + if let Some(v) = self.italic { + r.italic = Some(v); + } + if let Some(u) = &self.underline { + r.underline = Some(u.clone()); + } + if let Some(c) = &self.color { + r.fill = Some(Fill::solid(c.clone())); + } + if let Some(v) = self.strike { + r.strike = Some(v); + } + if let Some(v) = self.baseline { + r.baseline = Some(v); + } + if let Some(v) = self.caps { + r.caps = Some(v); + } + if let Some(v) = self.spacing { + r.spacing = Some(v); + } + } +} + +fn font_bool(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), + } +} + +fn font_str<'a>(p: &'a Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), + } +} + +fn font_edits(p: &Value) -> Result { + let family = match font_str(p, "family")? { + None => None, + Some(s) => { + let t = s.trim(); + if t.is_empty() || t.chars().count() > 200 { + return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); + } + Some(t.to_string()) + } + }; + let size = match p.get("size") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; + Some(n.clamp(1.0, 4000.0)) + } + }; + let underline = match font_str(p, "underline")? { + None => None, + Some(s) => { + if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { + return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); + } + Some(s.to_string()) + } + }; + let color = + if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; + let strike = match font_str(p, "strike")? { + None => None, + Some("none") => Some(Strike::None), + Some("sng" | "single") => Some(Strike::Single), + Some("dbl" | "double") => Some(Strike::Double), + Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), + }; + let baseline = match p.get("baseline") { + None => None, + Some(Value::String(s)) => match s.as_str() { + "none" => Some(0.0), + "super" | "superscript" => Some(0.3), + "sub" | "subscript" => Some(-0.25), + _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }, + Some(Value::Number(n)) => { + let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; + Some(f.clamp(-1.0, 1.0)) + } + Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }; + let caps = match font_str(p, "caps")? { + None => None, + Some("none") => Some(Caps::None), + Some("small") => Some(Caps::Small), + Some("all") => Some(Caps::All), + Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), + }; + let spacing = match p.get("spacing") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; + Some(n.clamp(-100.0, 400.0)) + } + }; + Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) +} + +/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. +fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { + use deckcraft_model::ColorBase; + let mut o = serde_json::Map::new(); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().to_string(), json!(v)); + } + } + match &c.base { + ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), + ColorBase::Rgb { rgb } => { + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + Value::Object(o) + } + ColorBase::Scheme { slot } => { + o.insert("scheme".into(), json!(slot.xml_name())); + Value::Object(o) + } + ColorBase::Preset { name } => json!(name), + ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), + } +} + +fn font_dialog_params(s: &Session) -> Value { + let r = current_run(s); + let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); + let baseline = r.baseline.unwrap_or(0.0); + let mut v = json!({ + "family": family, + "size": r.size.unwrap_or(18.0), + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), + "strike": match r.strike { + Some(Strike::Double) => "dbl", + Some(Strike::Single) => "sng", + _ => "none", + }, + "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, + "caps": match r.caps { + Some(Caps::Small) => "small", + Some(Caps::All) => "all", + _ => "none", + }, + "spacing": r.spacing.unwrap_or(0.0), + }); + if let Some(Fill::Solid { color }) = &r.fill { + v["color"] = color_command_value(color); + } + v +} + +fn font_dialog(s: &mut Session, p: &Value) -> Result { + let edits = font_edits(p)?; + if edits.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); + return ok(); + } + format_run(s, &|r| edits.write(r)) +} fn clear(s: &mut Session, _p: &Value) -> Result { format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) } @@ -662,6 +897,179 @@ fn paragraph(s: &mut Session, p: &Value) -> Result { }) } +const PARA_DIALOG_KEYS: &[&str] = + &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; + +fn para_dialog_has_fields(p: &Value) -> bool { + PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) +} + +fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; + Ok(Some(n)) + } + } +} + +fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + Ok(match s { + "left" | "l" => Align::Left, + "center" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + }) +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::JustLow => "justLow", + Align::Distributed => "distributed", + } +} + +fn spacing_parts(s: Option) -> (&'static str, f64) { + match s { + Some(Spacing::Pct(v)) => ("pct", v), + Some(Spacing::Pts(v)) => ("pts", v), + None => ("pts", 0.0), + } +} + +fn paragraph_dialog_params(s: &Session) -> Value { + let pp = current_para(s); + let indent = pp.indent.unwrap_or(0.0); + let (special, by) = if indent > 0.05 { + ("first", indent) + } else if indent < -0.05 { + ("hanging", -indent) + } else { + ("none", 0.0) + }; + let (line_kind, line_value) = match pp.line_spacing { + Some(Spacing::Pts(v)) => ("exactly", v), + Some(Spacing::Pct(v)) => ("multiple", v), + None => ("multiple", 1.0), + }; + let (before_kind, before) = spacing_parts(pp.space_before); + let (after_kind, after) = spacing_parts(pp.space_after); + let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({ + "align": align_name(pp.align.unwrap_or(Align::Left)), + "indentLeft": pp.margin_left.unwrap_or(0.0), + "indentRight": pp.margin_right.unwrap_or(0.0), + "special": special, + "specialBy": by, + "spaceBefore": before, + "spaceBeforeKind": before_kind, + "spaceAfter": after, + "spaceAfterKind": after_kind, + "lineKind": line_kind, + "lineValue": line_value, + "tabs": tabs, + "defaultTab": pp.default_tab.unwrap_or(72.0), + }) +} + +fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { + if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } +} + +fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.paragraphDialog"; + if !para_dialog_has_fields(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); + return ok(); + } + let align = match p.get("align") { + None => None, + Some(Value::String(a)) => Some(parse_align(CMD, a)?), + Some(_) => return Err(bad(CMD, "`align` must be a string")), + }; + let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); + let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); + let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); + let indent = match p.get("special") { + None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), + Some(Value::String(kind)) => { + let by = special_by.unwrap_or(0.0); + Some(match kind.as_str() { + "none" => 0.0, + "first" | "firstLine" => by, + "hanging" | "hang" => -by, + _ => return Err(bad(CMD, "`special` must be none, first or hanging")), + }) + } + Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), + }; + let space_before = finite_key(p, CMD, "spaceBefore")?; + let space_after = finite_key(p, CMD, "spaceAfter")?; + let before_kind = str_param(p, "spaceBeforeKind"); + let after_kind = str_param(p, "spaceAfterKind"); + for kind in [before_kind, after_kind].into_iter().flatten() { + if !matches!(kind, "pts" | "pct") { + return Err(bad(CMD, "spacing kind must be pts or pct")); + } + } + let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); + let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + format_para(s, &move |para| { + if align.is_some() { + para.props.align = align; + } + if indent_left.is_some() { + para.props.margin_left = indent_left; + } + if indent_right.is_some() { + para.props.margin_right = indent_right; + } + if indent.is_some() { + para.props.indent = indent; + } + if let Some(v) = space_before { + para.props.space_before = Some(write_spacing(before_kind, v)); + } + if let Some(v) = space_after { + para.props.space_after = Some(write_spacing(after_kind, v)); + } + if let Some(v) = line_mult { + para.props.line_spacing = Some(Spacing::Pct(v)); + } + if let Some(v) = line_pt { + para.props.line_spacing = Some(Spacing::Pts(v)); + } + }) +} + +fn tab_align(s: &str) -> Option<&'static str> { + Some(match s { + "l" | "left" => "l", + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => return None, + }) +} + +fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) +} + fn anchor(s: &mut Session, p: &Value) -> Result { let a = match str_param(p, "anchor").unwrap_or("top") { "middle" | "center" | "ctr" => Anchor::Middle, @@ -825,3 +1233,359 @@ fn font_rescan(_s: &mut Session, _p: &Value) -> Result { let faces = 0usize; Ok(json!({"faces": faces, "families": db.families().len()})) } + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +fn ruler(s: &mut Session, _p: &Value) -> Result { + let props = current_para(s); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; + let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { + (false, Some(sh)) => { + let ctx = super::text::ctx_for(&st.doc, &st.selection); + let x = match &ctx { + Some(c) => resolve::xfrm(c, sh), + None => sh.xfrm.unwrap_or_default(), + }; + let body = match &ctx { + Some(c) => resolve::body(c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); + (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) + } + _ => (0.0, 0.0, 0.0, true), + }; + let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + Ok(json!({ + "x": x, "y": y, "width": w, "rotated": rotated, + "marginLeft": props.margin_left.unwrap_or(0.0), + "indent": props.indent.unwrap_or(0.0), + "tabs": tabs, + "defaultTab": props.default_tab.unwrap_or(72.0), + })) +} + +const MAX_TABS: usize = 32; + +fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); + let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => "l", + }; + let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); + let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); + let remove = fin(p.get("remove").and_then(Value::as_f64)); + // `set` replaces every stop: entries without a usable position are dropped, an unknown + // alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins). + let set = match p.get("set") { + None => None, + Some(Value::Array(items)) => { + if items.len() > 64 { + return Err(bad(CMD, "`set` has more than 64 tab stops")); + } + let mut out: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + return Err(bad(CMD, format!("tab stop {i} must be an object"))); + }; + let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else { + continue; + }; + let align = match obj.get("align").and_then(Value::as_str) { + None => "l", + Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, + }; + if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { + *prev = TabStop { pos, align: align.to_string() }; + } else { + out.push(TabStop { pos, align: align.to_string() }); + } + } + Some(out) + } + Some(_) => return Err(bad(CMD, "`set` must be an array")), + }; + let clear = bool_or(p, "clear", false); + let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); + let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear; + if !edits_stops && default_tab.is_none() { + // No fields: open the Tabs dialog on the caret's paragraph. + s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); + return ok(); + } + // The stops as the caret's paragraph shows them (inherited ones included), so editing a + // placeholder's tab that came from its layout works like editing one set on the paragraph. + let base = current_para(s).tabs.unwrap_or_default(); + let near = |a: f64, b: f64| (a - b).abs() < 0.5; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; + if let Some(at) = remove { + stops.retain(|t| !near(t.pos, at)); + } + if let Some((from, to)) = mv + && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) + { + t.pos = to; + } + if let Some((pos, align)) = add { + stops.retain(|t| !near(t.pos, pos)); + stops.push(TabStop { pos, align }); + } + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops.dedup_by(|a, b| near(a.pos, b.pos)); + stops.truncate(MAX_TABS); + format_para(s, &move |para| { + if edits_stops { + para.props.tabs = Some(stops.clone()); + } + if let Some(d) = default_tab { + para.props.default_tab = Some(d); + } + }) +} + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Align, Caps, ParaProps, Spacing, Strike}; + use deckcraft_model::{ColorBase, Fill, SchemeSlot}; + use serde_json::json; + + use super::*; + use crate::{Session, UiRequest}; + + fn shape() -> Session { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap(); + s + } + + fn props(s: &Session) -> RunProps { + s.doc() + .unwrap() + .selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .unwrap() + .props + .clone() + } + + #[test] + fn font_dialog_applies_character_formatting_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.fontDialog", + &json!({ + "family": "Liberation Serif", + "size": 28, + "bold": true, + "italic": false, + "underline": "wavy", + "color": "#112233", + "strike": "dbl", + "baseline": "super", + "caps": "small", + "spacing": 3 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…"); + let r = props(&s); + assert_eq!(r.font.as_deref(), Some("Liberation Serif")); + assert_eq!(r.size, Some(28.0)); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.italic, Some(false)); + assert_eq!(r.underline.as_deref(), Some("wavy")); + assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. }))); + assert_eq!(r.strike, Some(Strike::Double)); + assert_eq!(r.baseline, Some(0.3)); + assert_eq!(r.caps, Some(Caps::Small)); + assert_eq!(r.spacing, Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none()); + assert_eq!(r.strike, None); + assert_eq!(r.caps, None); + } + + #[test] + fn font_dialog_changes_only_the_fields_it_is_given() { + let mut s = shape(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.size, Some(22.0)); + assert!(r.font.is_none() && r.italic.is_none()); + let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") }; + assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 })); + assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod")); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert!(r.size.is_none() && r.fill.is_none()); + } + + #[test] + fn font_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + s.execute("format.italic", &json!({"on": true})).unwrap(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.fontDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "font"); + assert_eq!(params["italic"], true); + assert_eq!(params["bold"], false); + assert!(params["family"].as_str().is_some_and(|f| !f.is_empty())); + assert!(params["size"].as_f64().is_some_and(|n| n > 0.0)); + } + + #[test] + fn font_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err(); + assert!(err.to_string().contains("underline"), "{err}"); + assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert!(props(&s).underline.is_none()); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); + } + + fn para(s: &Session) -> ParaProps { + s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).and_then(|t| t.paragraphs.first()).unwrap().props.clone() + } + + #[test] + fn paragraph_dialog_applies_alignment_indent_and_spacing_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.paragraphDialog", + &json!({ + "align": "justify", + "indentLeft": 36.0, + "indentRight": 18.0, + "special": "hanging", + "specialBy": 18.0, + "spaceBefore": 6.0, + "spaceAfter": 8.0, + "lineSpacing": 1.5 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…"); + let p = para(&s); + assert_eq!(p.align, Some(Align::Justify)); + assert_eq!(p.margin_left, Some(36.0)); + assert_eq!(p.margin_right, Some(18.0)); + assert_eq!(p.indent, Some(-18.0)); + assert_eq!(p.space_before, Some(Spacing::Pts(6.0))); + assert_eq!(p.space_after, Some(Spacing::Pts(8.0))); + assert_eq!(p.line_spacing, Some(Spacing::Pct(1.5))); + s.execute("edit.undo", &json!({})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote"); + assert!(p.margin_left.is_none() && p.indent.is_none() && p.space_before.is_none() && p.line_spacing.is_none()); + } + + #[test] + fn paragraph_dialog_first_line_and_exact_spacing_replace_only_those_fields() { + let mut s = shape(); + s.execute("format.paragraphDialog", &json!({"align": "right"})).unwrap(); + s.execute("format.paragraphDialog", &json!({"special": "first", "specialBy": 27.0, "lineSpacingPt": 20.0})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Right)); + assert_eq!(p.indent, Some(27.0)); + assert_eq!(p.line_spacing, Some(Spacing::Pts(20.0))); + assert!(p.margin_left.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).align, Some(Align::Right)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn paragraph_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.paragraphDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "paragraph"); + assert_eq!(params["align"], "center", "a new shape's text is centred"); + assert_eq!(params["special"], "none"); + assert_eq!(params["lineKind"], "multiple"); + assert!(params["lineValue"].as_f64().is_some_and(|v| v > 0.0)); + } + + #[test] + fn paragraph_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + assert!(s.execute("format.paragraphDialog", &json!({"align": "sparkle"})).unwrap_err().to_string().contains("align")); + assert!(s.execute("format.paragraphDialog", &json!({"special": "middle"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"indentLeft": "wide"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"lineSpacing": "double"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"spaceBeforeKind": "lines", "spaceBefore": 4.0})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(para(&s).align, Some(Align::Center)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.tabs", + &json!({"set": [{"pos": 144.0, "align": "center"}, {"pos": 72.0, "align": "dec"}, {"pos": 72.02, "align": "right"}], "defaultTab": 36.0}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Tabs…"); + let p = para(&s); + let tabs = p.tabs.unwrap(); + assert_eq!(tabs.len(), 2, "stops within 0.05 pt collapse"); + assert_eq!(tabs[0].pos, 72.02); + assert_eq!(tabs[0].align, "r"); + assert_eq!(tabs[1].align, "ctr"); + assert_eq!(p.default_tab, Some(36.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(para(&s).tabs.is_none() && para(&s).default_tab.is_none()); + + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "tabs"); + assert!(params["tabs"].as_array().is_some_and(|a| a.is_empty())); + assert!(s.execute("format.tabs", &json!({"set": [{"pos": 10.0, "align": "sparkle"}]})).is_err()); + assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + } +} diff --git a/crates/engine/src/cmd/guides_tests.rs b/crates/engine/src/cmd/guides_tests.rs new file mode 100644 index 00000000..3211b6bb --- /dev/null +++ b/crates/engine/src/cmd/guides_tests.rs @@ -0,0 +1,76 @@ +//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and +//! the PPTX round trip. + +use serde_json::json; + +use crate::Session; +use crate::tools::{Mods, PointerEvent, PointerKind}; + +fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0 }).unwrap(); +} + +#[test] +fn add_move_delete_and_undo() { + let mut s = Session::with_new(); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none"); + let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap(); + assert_eq!(i, 2); + s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide"); + assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err()); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err()); + s.execute("guide.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 3); +} + +#[test] +fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + // Hidden guides can't be grabbed: the press starts a marquee instead. + ev(&mut s, PointerKind::Down, 300.0, 50.0); + ev(&mut s, PointerKind::Up, 300.0, 50.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0); + s.tool.guides_visible = true; + ev(&mut s, PointerKind::Down, 301.0, 50.0); + ev(&mut s, PointerKind::Drag, 350.4, 60.0); + assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging"); + ev(&mut s, PointerKind::Up, 350.4, 60.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0); + assert_eq!(s.tool.guide_drag, None); + + // A shape dragged near the guide snaps its left edge onto it. + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap(); + s.prefs.snap_to_grid = false; + ev(&mut s, PointerKind::Down, 140.0, 230.0); + ev(&mut s, PointerKind::Drag, 200.0, 230.0); + ev(&mut s, PointerKind::Drag, 388.0, 231.0); + ev(&mut s, PointerKind::Up, 388.0, 231.0); + let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap(); + assert!((x.x - 350.0).abs() < 0.01, "{x:?}"); + + // Dropped off the slide: gone, and undo brings it back. + ev(&mut s, PointerKind::Down, 350.0, 10.0); + ev(&mut s, PointerKind::Drag, -40.0, 10.0); + ev(&mut s, PointerKind::Up, -40.0, 10.0); + assert!(s.doc().unwrap().doc.guides.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 1); +} + +#[test] +fn guides_round_trip_through_pptx() { + let mut s = Session::with_new(); + s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap(); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert_eq!(back.guides, s.doc().unwrap().doc.guides); + let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap(); + assert!(none.guides.is_empty()); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 610a0d57..86300d58 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub mod background; pub mod design; pub mod edit; pub mod file; +pub mod find; pub mod format; pub mod ideas; pub mod insert; @@ -153,6 +154,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); + v.extend(find::specs()); v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index a19a80a6..52045e99 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -46,6 +46,20 @@ pub fn specs() -> Vec { ), cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary), cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info), + cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus), + cmd!( + "review.replaceWord", + "Insert Synonym", + [], + None, + "{with}: replace the selected word (or the word at the caret) while editing text", + has_text, + insert_synonym + ), + cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare), + cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions), + cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept), + cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { let i = s.doc()?.selection.slide; @@ -581,6 +595,116 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { ok() } +/// The selected text when it lies in one paragraph, else the word at the caret. +fn word_under_caret(s: &Session) -> Option { + let st = s.doc().ok()?; + let t = st.selection.text.clone()?; + let body = super::text::body_of(st, &t)?; + let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) }; + if a.0 != b.0 { + return None; + } + let text = body.paragraphs.get(a.0)?.text(); + let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect(); + let w = w.trim().to_string(); + (!w.is_empty()).then_some(w) +} + +fn thesaurus(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default(); + let senses = crate::thesaurus::lookup(&word); + Ok(json!({"word": word, "senses": senses})) +} + +fn insert_synonym(s: &mut Session, p: &Value) -> Result { + let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string(); + if with.chars().count() > 200 { + return Err(bad("review.replaceWord", "too long")); + } + let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret); + if collapsed { + s.execute("text.selectWord", &json!({}))?; + } + s.execute("text.insert", &json!({"text": with})) +} + +fn comparing(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } +} + +fn revised_bytes(p: &Value) -> Result<(String, Vec)> { + if let Some(data) = str_param(p, "data") { + let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = str_param(p, "path") { + let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + return Ok((path.to_string(), bytes)); + } + Err(bad("review.compare", "give `path` or `data`")) +} + +fn revisions_json(st: &crate::DocState) -> Result { + let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?; + let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone()); + Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position})) +} + +fn compare(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = revised_bytes(p)?; + let revised = super::file::open_presentation(&name, &bytes)?; + let st = s.doc_mut()?; + let by_position = crate::compare::unrelated(&st.doc, &revised); + st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position }); + s.ui_requests.push(UiRequest::Pane { id: "revisions".into() }); + revisions_json(s.doc()?) +} + +fn revisions(s: &mut Session, _: &Value) -> Result { + revisions_json(s.doc()?) +} + +fn accept(s: &mut Session, p: &Value) -> Result { + let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?; + let all = bool_param(p, "all").unwrap_or(false); + let index = usize_param(p, "index"); + if !all && index.is_none() { + return Err(bad("review.accept", "give `index` or `all`")); + } + s.edit(|doc, sel| { + let mut accepted = 0usize; + // Each accept shifts indices, so the list is recomputed; bounded by the slide counts. + let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1); + for _ in 0..limit { + let changes = crate::compare::diff(doc, &rev, by_pos); + let c = if all { + changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1)) + } else { + changes.into_iter().nth(index.unwrap_or(0)) + }; + let Some(c) = c else { break }; + let Some(at) = crate::compare::accept(doc, &rev, &c) else { break }; + accepted += 1; + sel.slide = at; + sel.shapes.clear(); + sel.text = None; + if !all { + break; + } + } + if accepted == 0 { + return Err(bad("review.accept", "no such revision")); + } + Ok(json!({"accepted": accepted})) + }) +} + +fn end_compare(s: &mut Session, _: &Value) -> Result { + s.doc_mut()?.compare = None; + ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs new file mode 100644 index 00000000..e920ce93 --- /dev/null +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -0,0 +1,64 @@ +//! The ruler's data and tab-stop editing. + +use serde_json::json; + +use crate::Session; + +fn editing() -> (Session, u64) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "a\tb"})).unwrap(); + (s, id) +} + +#[test] +fn ruler_reports_the_text_area_and_indents() { + let (mut s, _) = editing(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}"); + assert_eq!(r["rotated"], false); + assert_eq!(r["tabs"].as_array().unwrap().len(), 0); + s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0))); + s.execute("text.exit", &json!({})).unwrap(); + assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text"); +} + +#[test] +fn tabs_add_move_remove_clear_and_undo() { + let (mut s, _) = editing(); + let tabs = |s: &mut Session| -> Vec<(f64, String)> { + s.execute("format.ruler", &json!({})).unwrap()["tabs"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string())) + .collect() + }; + s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap(); + s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted"); + s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced"); + s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap(); + s.execute("format.tabs", &json!({"remove": 72.2})).unwrap(); + assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tabs(&mut s).len(), 2); + s.execute("format.tabs", &json!({"clear": true})).unwrap(); + assert!(tabs(&mut s).is_empty()); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog"); + assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); + for i in 0..100 { + s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); + } + assert!(tabs(&mut s).len() <= 32); + s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap(); + assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped"); + assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0)); +} diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 8ba47f74..16e70f6e 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -2,7 +2,7 @@ //! names and alt text. use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; -use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow}; +use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow}; use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; use serde_json::{Value, json}; @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}", + "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill", has_selection, fill ), @@ -528,8 +528,9 @@ fn fill(s: &mut Session, p: &Value) -> Result { None }; let transparency = f64_param(p, "transparency"); - if new_fill.is_none() && transparency.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`")); + let pic_props = p.get("pictureProps").filter(|v| v.is_object()); + if new_fill.is_none() && transparency.is_none() && pic_props.is_none() { + return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`")); } // Transparency on an inherited (style) fill needs the effective colour. let st = s.doc()?; @@ -557,6 +558,14 @@ fn fill(s: &mut Session, p: &Value) -> Result { if let Some(f) = &new_fill { sh.fill.clone_from(f); } + if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) { + picture_props(pf, pp); + } + if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); + return Ok(()); + } if let Some(t) = transparency { let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone())); if let Some(Fill::Solid { mut color }) = base { @@ -571,6 +580,54 @@ fn fill(s: &mut Session, p: &Value) -> Result { }) } +/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. +fn picture_props(pf: &mut PictureFill, pp: &Value) { + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + fn line_end(v: &str) -> Option { if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } } diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs new file mode 100644 index 00000000..5d65c0cc --- /dev/null +++ b/crates/engine/src/compare.rs @@ -0,0 +1,449 @@ +//! Review › Compare: the differences between the open presentation and a revised copy of it, slide +//! by slide, and accepting a revised slide into the open one. +//! +//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no +//! slide id at all, by position. Shapes match by id, then by name. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId}; +use serde::Serialize; + +/// The revised presentation a document is being compared with. +#[derive(Clone, Debug)] +pub struct Compare { + pub name: String, + pub revised: Arc, + /// Slides match by position (the decks shared no slide id when the compare started); fixed + /// for the session so accepting slides doesn't switch the matching half way. + pub by_position: bool, +} + +/// Whether `cur` and `rev` share no slide id (then slides match by position). +pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool { + let ids: HashSet = rev.slides.iter().map(|s| s.id).collect(); + !cur.slides.iter().any(|s| ids.contains(&s.id)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// Only in the revised deck. + Inserted, + /// Only in the open deck. + Deleted, + /// In both, different. + Changed, +} + +/// One slide-level revision. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + /// Index in the open deck (for inserted: where it would go). + pub slide: usize, + /// Index in the revised deck (none for deleted). + pub revised: Option, + /// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`… + pub details: Vec, +} + +const MAX_DETAILS: usize = 50; + +/// Slide pairs (open index, revised index). +fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> { + if by_position { + return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect(); + } + let rev_ids: HashMap = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect(); + cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect() +} + +pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec { + let pairs = pairs(cur, rev, by_position); + let cur_matched: HashSet = pairs.iter().map(|p| p.0).collect(); + let rev_matched: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = vec![]; + for (i, j) in &pairs { + let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; + let details = slide_details(cur, a, rev, b); + if !details.is_empty() { + out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details }); + } + } + for (i, s) in cur.slides.iter().enumerate() { + if !cur_matched.contains(&i) { + out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] }); + } + } + for (j, s) in rev.slides.iter().enumerate() { + if !rev_matched.contains(&j) { + // After the open slide that matches the revised slide before it. + let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0); + out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] }); + } + } + out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0))); + out +} + +fn slide_title(s: &Slide) -> String { + let t = s + .shapes + .iter() + .find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle))) + .and_then(|x| x.text.as_ref()) + .map(|b| b.text()) + .unwrap_or_default(); + let t = t.trim(); + if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() } +} + +fn shape_label(s: &Shape) -> String { + if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } +} + +fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { + p.media(id).map(|m| m.data.as_slice()) +} + +/// A shape without its ids (shape and media), for comparing across decks. +fn plain_shape(s: &Shape) -> Shape { + let mut c = s.clone(); + fn rec(shapes: &mut [Shape]) { + for s in shapes { + s.id = ShapeId(0); + if let Some(ch) = s.children_mut() { + rec(ch); + } + } + } + rec(std::slice::from_mut(&mut c)); + c +} + +fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec) { + let label = shape_label(b); + let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); + let mut what = vec![]; + if text(a) != text(b) { + what.push("text"); + } else if a.text != b.text { + what.push("text formatting"); + } + match (&a.xfrm, &b.xfrm) { + (Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"), + (Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"), + (x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"), + _ => {} + } + if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { + what.push("formatting"); + } + let pic = |p: &Presentation, s: &Shape| match &s.kind { + ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), + _ => None, + }; + if pic(cp, a) != pic(rp, b) { + what.push("picture"); + } + if what.is_empty() { + let (mut x, mut y) = (plain_shape(a), plain_shape(b)); + x.text = None; + y.text = None; + if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) { + x.kind = ShapeKind::Shape; + y.kind = ShapeKind::Shape; + } + if x != y { + what.push("properties"); + } + } + if !what.is_empty() { + out.push(format!("{label}: {}", what.join(", "))); + } +} + +fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec { + let mut out = vec![]; + let mut used: HashSet = HashSet::new(); + for sa in &a.shapes { + let k = b + .shapes + .iter() + .position(|x| x.id == sa.id) + .or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); + match k { + Some(k) if !used.contains(&k) => { + used.insert(k); + if let Some(sb) = b.shapes.get(k) { + shape_details(cp, sa, rp, sb, &mut out); + } + } + _ => out.push(format!("{} deleted", shape_label(sa))), + } + } + for (k, sb) in b.shapes.iter().enumerate() { + if !used.contains(&k) { + out.push(format!("{} inserted", shape_label(sb))); + } + } + let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::>(); + if out.is_empty() && order(a) != order(b) { + out.push("order of objects".into()); + } + let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone()); + if layout_name(cp, a) != layout_name(rp, b) { + out.push("layout".into()); + } + if a.notes.text() != b.notes.text() { + out.push("notes".into()); + } + if a.transition != b.transition { + out.push("transition".into()); + } + if a.animations.len() != b.animations.len() + || a.animations + .iter() + .zip(&b.animations) + .any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms)) + { + out.push("animations".into()); + } + if a.background != b.background { + out.push("background".into()); + } + if a.hidden != b.hidden { + out.push(if b.hidden { "hidden" } else { "shown" }.into()); + } + out.truncate(MAX_DETAILS); + out +} + +fn max_shape_id(shapes: &[Shape], m: &mut u32) { + deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0)); +} + +/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape +/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its +/// layout matched by id, then by name, then the first layout. +pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option) -> Slide { + let mut sl = slide.clone(); + // Media. + let mut mids = vec![]; + crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids); + let mut media_map: HashMap = HashMap::new(); + for id in mids { + let Some(m) = rev.media(id) else { continue }; + match doc.media(id) { + Some(x) if x.data == m.data => {} + None => doc.media.push(m.clone()), + Some(_) => { + let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + media_map.insert(id, new); + } + } + } + // Shape ids used elsewhere in the open deck. + let mut taken: HashSet = HashSet::new(); + for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + taken.insert(x.id.0); + }); + } + let mut id_map: HashMap = HashMap::new(); + fn rec( + doc: &mut Presentation, + shapes: &mut [Shape], + taken: &HashSet, + ids: &mut HashMap, + media: &HashMap, + depth: usize, + ) { + if depth > 64 { + return; + } + for s in shapes { + if taken.contains(&s.id.0) { + let new = ShapeId(doc.alloc_id()); + ids.insert(s.id, new); + s.id = new; + } + let remap = |m: &mut MediaId| { + if let Some(n) = media.get(m) { + *m = *n; + } + }; + match &mut s.kind { + ShapeKind::Picture { fill } => remap(&mut fill.media), + ShapeKind::Media(m) => { + remap(&mut m.media); + if let Some(p) = m.poster.as_mut() { + remap(p); + } + } + _ => {} + } + if let Some(Fill::Picture(pf)) = &mut s.fill { + remap(&mut pf.media); + } + if let Some(ch) = s.children_mut() { + rec(doc, ch, taken, ids, media, depth + 1); + } + } + } + rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0); + for a in &mut sl.animations { + if let Some(n) = id_map.get(&a.shape) { + a.shape = *n; + } + if let Some(t) = a.trigger.as_mut() + && let Some(n) = id_map.get(t) + { + *t = *n; + } + } + let mut max = 0; + max_shape_id(&sl.shapes, &mut max); + doc.next_id = doc.next_id.max(max); + // Layout. + if doc.layout(sl.layout).is_none() { + let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone()); + let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id)); + sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default(); + } + match replacing { + Some(id) => sl.id = id, + None => { + if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 { + sl.id = SlideId(doc.alloc_id()); + } + doc.next_id = doc.next_id.max(sl.id.0); + } + } + sl +} + +/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show. +pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option { + match change.kind { + ChangeKind::Changed => { + let b = rev.slides.get(change.revised?)?.clone(); + let id = doc.slides.get(change.slide)?.id; + let sl = adopt(doc, rev, &b, Some(id)); + *doc.slides.get_mut(change.slide)? = Arc::new(sl); + Some(change.slide) + } + ChangeKind::Inserted => { + let b = rev.slides.get(change.revised?)?.clone(); + let sl = adopt(doc, rev, &b, None); + let at = change.slide.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(sl)); + Some(at) + } + ChangeKind::Deleted => { + if change.slide >= doc.slides.len() || doc.slides.len() <= 1 { + return None; + } + doc.slides.remove(change.slide); + Some(change.slide.min(doc.slides.len().saturating_sub(1))) + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + /// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title + /// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3. + fn decks() -> (Session, String, usize) { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + // Make the revision in a second document. + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0; + s.execute("edit.select", &json!({"ids": [first]})).unwrap(); + s.execute("text.edit", &json!({"id": first})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Revised title"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).ok(); + let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0; + s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok(); + s.execute("slide.go", &json!({"index": n - 2})).ok(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + (s, rev, n) + } + + fn kinds(v: &Value) -> Vec { + v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect() + } + + #[test] + fn lists_inserted_deleted_and_changed_slides() { + let (mut s, rev, n) = decks(); + let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let k = kinds(&v); + assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}"); + let ch = &v["changes"].as_array().unwrap()[0]; + assert_eq!(ch["slide"], 0); + assert!(ch["details"].to_string().contains("text"), "{ch}"); + let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap(); + assert_eq!(ins["details"][0], "Brand new"); + let at = ins["slide"].as_u64().unwrap() as usize; + assert_eq!(at, n, "after the open slide matching the revised one before it"); + assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); + } + + #[test] + fn accept_one_then_all_makes_the_decks_equal_and_undoes() { + let (mut s, rev, n) = decks(); + s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + s.execute("review.accept", &json!({"index": 0})).unwrap(); + let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + assert_eq!(after, before - 1); + assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title")))); + s.execute("review.accept", &json!({"all": true})).unwrap(); + let left = s.execute("review.revisions", &json!({})).unwrap(); + assert!(left["changes"].as_array().unwrap().is_empty(), "{left}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate()); + assert_eq!(s.doc().unwrap().doc.slides.len(), n); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before); + s.execute("review.endCompare", &json!({})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"all": true})).is_err()); + } + + #[test] + fn unrelated_decks_match_by_position_and_bad_input_fails() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap(); + let mut other = Session::with_new(); + other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap(); + other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap(); + assert!(kinds(&v).contains(&"inserted".into()), "{v}"); + s.execute("review.accept", &json!({"all": true})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty()); + assert!(s.doc().unwrap().doc.validate().is_empty()); + assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err()); + assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err()); + assert!(s.execute("review.compare", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"index": 999})).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ea851476..b6c0bbe7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod compare; pub mod connect; pub mod drawtable; pub mod fonts; @@ -21,6 +22,7 @@ pub mod sample; pub mod sounds; pub mod spell; pub mod templates; +pub mod thesaurus; pub mod tools; use std::sync::Arc; @@ -214,6 +216,8 @@ pub struct DocState { pub missing_fonts: Vec, /// An input method's composition in progress ([`Composition`]). pub composition: Option, + /// Review › Compare in progress: the revised copy. + pub compare: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -240,6 +244,7 @@ impl DocState { title, missing_fonts: Vec::new(), composition: None, + compare: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2f425aa9..3082d893 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2558,3 +2558,109 @@ fn smooth_corners_makes_a_squircle_outline() { let sh = back.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. })); assert!(sh.is_some(), "a custom outline in the saved file"); } + +#[test] +fn wordart_adjust_handle_drags_and_undoes() { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let id = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap(); + let sid = model::ShapeId(id as u32); + { + let st = s.doc_mut().unwrap(); + let slide = st.selection.slide; + let mut doc = (*st.doc).clone(); + let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap(); + sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into()); + st.doc = std::sync::Arc::new(doc); + } + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let (at, top) = { + let st = s.doc().unwrap(); + let sh = st.shape(sid).unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, sh); + let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h); + assert_eq!(hs.len(), 1); + let at = x.affine() * hs[0].pos; + (at, deckcraft_geom::Point::new(x.center().x, x.y)) + }; + assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. }))); + let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0 }; + s.pointer(ev(PointerKind::Down, at)).unwrap(); + s.pointer(ev(PointerKind::Drag, top)).unwrap(); + s.pointer(ev(PointerKind::Up, top)).unwrap(); + let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone(); + let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0; + assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body; + assert_eq!(body.warp.as_deref(), Some("textArchUp")); + assert!(body.warp_adj.is_empty(), "undo restores the default adjusts"); +} + +#[test] +fn shape_picture_fill_options_tile_offset_and_round_trip() { + use deckcraft_model::style::PictureMode; + let mut img = image::RgbaImage::new(4, 4); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let pf = |s: &Session| match s.doc().unwrap().selected_shapes()[0].fill.clone() { + Some(deckcraft_model::Fill::Picture(pf)) => pf, + other => panic!("{other:?}"), + }; + // Options without a picture fill are a no-op, not an error. + s.execute("shape.fill", &json!({"pictureProps": {"tile": true}})).unwrap(); + s.execute("shape.fill", &json!({"picture": cmd::base64_encode(&png)})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"stretch": [0.1, 0.2, 5, "x"]}})).unwrap(); + assert_eq!(pf(&s).mode, PictureMode::Stretch { fill_rect: [0.1, 0.2, 0.99, 0.0] }); + s.execute("shape.fill", &json!({"pictureProps": {"tile": true, "offsetX": 12, "offsetY": -3, "scaleX": 0.5, "scaleY": 50, "align": "ctr", "mirror": "xy", "rotateWithShape": false}})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"align": "bogus", "offsetX": f64::NAN}, "transparency": 0.4})).unwrap(); + let want = PictureMode::Tile { tx: 12.0, ty: -3.0, sx: 0.5, sy: 10.0, flip: "xy".into(), align: "ctr".into() }; + let got = pf(&s); + assert_eq!(got.mode, want); + assert!(got.upright); + assert!((got.alpha.unwrap() - 0.6).abs() < 1e-9); + // PPTX round trip keeps the options. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "p.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let doc = &s.doc().unwrap().doc; + let back = doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.fill { + Some(deckcraft_model::Fill::Picture(pf)) => Some(pf.clone()), + _ => None, + }); + let back = back.unwrap(); + assert_eq!(back.mode, want); + assert!(back.upright); + assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6); +} + +#[test] +fn find_and_replace_across_shapes_and_notes() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old plan, old team\nboldly"})).unwrap(); + s.execute("slide.notes", &json!({"text": "Discuss the old plan"})).unwrap(); + let q = json!({"text": "old"}); + let found = s.execute("text.find", &q).unwrap(); + let n = found.as_array().unwrap().len(); + assert!(n >= 3, "{found}"); + let whole = s.execute("text.find", &json!({"text": "old", "wholeWords": true, "matchCase": true})).unwrap(); + assert_eq!(whole.as_array().unwrap().len(), n - 2, "Old and boldly are not whole lower-case matches"); + + let first = found[0].clone(); + assert_eq!((first["para"].as_u64(), first["start"].as_u64()), (Some(0), Some(0))); + s.execute("text.replace", &json!({"text": "old", "with": "New", "at": first})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); + + let r = s.execute("text.replace", &json!({"text": "old", "with": "new", "wholeWords": true})).unwrap(); + assert_eq!(r["replaced"].as_u64(), Some(n as u64 - 2)); + let text = s.doc().unwrap().doc.slides[0].shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::>().join("|"); + assert!(text.contains("New plan, new team\nboldly"), "{text}"); + assert!(s.execute("text.replace", &json!({"text": "zebra", "with": "x"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); +} diff --git a/crates/engine/src/thesaurus.rs b/crates/engine/src/thesaurus.rs new file mode 100644 index 00000000..10cc207d --- /dev/null +++ b/crates/engine/src/thesaurus.rs @@ -0,0 +1,119 @@ +//! Review › Thesaurus: synonyms from a small built-in English word list (`data/thesaurus.txt`), +//! looked up offline so it works in the browser build too. + +const DATA: &str = include_str!("../data/thesaurus.txt"); + +/// One sense of a word: its headword and synonyms. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Sense { + pub word: String, + pub synonyms: Vec, +} + +fn senses_of(head: &str) -> Vec { + DATA.lines() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split_once('|')) + .filter(|(h, _)| h.trim() == head) + .map(|(h, s)| Sense { word: h.trim().to_string(), synonyms: s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() }) + .collect() +} + +/// The senses for `word`, trying simple English endings when the word itself isn't listed +/// ("goals" → "goal", "improved" → "improve", "growing" → "grow"). Synonyms take the word's +/// capitalisation ("Goal" → "Aim", "GOAL" → "AIM"). +pub fn lookup(word: &str) -> Vec { + let w: String = word.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != '-').chars().take(64).collect(); + let lower = w.to_lowercase(); + if lower.is_empty() { + return vec![]; + } + let mut tries = vec![lower.clone()]; + for (suffix, add) in [("ies", "y"), ("es", ""), ("s", ""), ("ied", "y"), ("ed", "e"), ("ed", ""), ("ing", "e"), ("ing", "")] { + if let Some(stem) = lower.strip_suffix(suffix) + && stem.chars().count() >= 2 + { + tries.push(format!("{stem}{add}")); + } + } + let Some(found) = tries.iter().map(|t| senses_of(t)).find(|v| !v.is_empty()) else { return vec![] }; + let upper = w.chars().count() > 1 && w.chars().all(|c| !c.is_lowercase()); + let title = !upper && w.chars().next().is_some_and(char::is_uppercase); + found + .into_iter() + .map(|s| Sense { + word: s.word, + synonyms: s + .synonyms + .into_iter() + .map(|x| { + if upper { + x.to_uppercase() + } else if title { + let mut c = x.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + x + } + }) + .collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn senses_endings_and_case() { + let g = lookup("goal"); + assert_eq!(g.len(), 1); + assert!(g[0].synonyms.contains(&"objective".to_string())); + assert_eq!(lookup("change").len(), 2, "verb and noun senses"); + assert_eq!(lookup("Goals")[0].synonyms[0], "Aim"); + assert_eq!(lookup("GOAL")[0].synonyms[0], "AIM"); + assert_eq!(lookup("improved")[0].word, "improve"); + assert_eq!(lookup("growing")[0].word, "grow"); + assert_eq!(lookup("strategies")[0].word, "strategy"); + assert_eq!(lookup("“goal,”")[0].word, "goal", "punctuation around the word"); + assert!(lookup("").is_empty() && lookup("zzxq").is_empty() && lookup(" ").is_empty()); + assert!(lookup(&"a".repeat(10_000)).is_empty()); + } + + #[test] + fn data_is_well_formed() { + for l in DATA.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let (h, s) = l.split_once('|').unwrap_or_else(|| panic!("no `|`: {l}")); + assert_eq!(h, h.trim().to_lowercase(), "{l}"); + let syn: Vec<&str> = s.split(',').map(str::trim).collect(); + assert!(syn.len() >= 2 && syn.iter().all(|x| !x.is_empty() && *x != h), "{l}"); + let mut d = syn.clone(); + d.sort(); + d.dedup(); + assert_eq!(d.len(), syn.len(), "duplicate synonym: {l}"); + } + } + + #[test] + fn commands_look_up_and_replace_the_word_at_the_caret() { + use serde_json::json; + let mut s = crate::Session::with_new(); + let id = s.execute("insert.textBox", &json!({"rect": [50, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let r = s.execute("review.thesaurus", &json!({})).unwrap(); + assert_eq!(r["word"], "Goal"); + assert_eq!(r["senses"][0]["synonyms"][0], "Aim"); + s.execute("review.replaceWord", &json!({"with": "Aim"})).unwrap(); + let text = |s: &crate::Session| { + s.doc().unwrap().shapes().iter().find(|x| x.id.0 as u64 == id).and_then(|x| x.text.as_ref()).map(|t| t.paragraphs[0].text()).unwrap() + }; + assert_eq!(text(&s), "Our Aim is growth."); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s), "Our Goal is growth."); + assert_eq!(s.execute("review.thesaurus", &json!({"word": "growth."})).unwrap()["senses"][0]["word"], "growth"); + assert!(s.execute("review.replaceWord", &json!({"with": ""})).is_err()); + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 97aca6db..fa34db56 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -92,6 +92,11 @@ pub enum Hit { path: usize, index: usize, }, + /// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`. + Warp { + id: ShapeId, + index: usize, + }, /// Line end 0/1 of a line-like shape. LineEnd { id: ShapeId, @@ -146,10 +151,22 @@ enum Drag { index: usize, out: bool, }, + /// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults). + Warp { + id: ShapeId, + index: usize, + name: String, + base: Vec, + orig: Xfrm, + }, Marquee { start: Point, add: bool, }, + /// Dragging drawing guide `index`. + Guide { + index: usize, + }, Create { start: Point, }, @@ -215,6 +232,12 @@ pub struct ToolState { pub edit_vertex: Option<(usize, usize)>, /// The point type last chosen for a vertex (path, index, type): handle drags follow it. pub vertex_kind: Option<(usize, usize, String)>, + /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. + pub guides_visible: bool, + /// The guide being dragged and where it is now (slide points); `None` once dropped. + pub guide_drag: Option<(usize, f64)>, + /// The drawing guide under the pointer, for the cursor. + pub guide_hover: Option, } impl ToolState { @@ -246,6 +269,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) { (pts, rot) } +/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the +/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on. +pub fn wordart_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + w: f64, + h: f64, +) -> Vec { + if sh.text.is_none() { + return Vec::new(); + } + let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() }; + let bp = deckcraft_model::resolve::body(&ctx, sh); + let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() }; + let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect; + deckcraft_geom::warp::handles(name, &bp.warp_adj, tr) +} + +/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults). +fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) { + let Some(text) = sh.text.as_mut() else { return }; + if !value.is_finite() || index > 7 { + return; + } + let defaults = deckcraft_geom::warp::adjust_defaults(name); + let n = defaults.len().max(index + 1).min(8); + if n == 0 { + return; + } + let mut adj = Vec::with_capacity(n); + for i in 0..n { + let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) }; + adj.push(v); + } + text.body.warp = Some(name.to_string()); + text.body.warp_adj = adj; +} + impl Session { pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; @@ -298,6 +360,11 @@ impl Session { return Some(Hit::Adjust { id: *id, index: hd.index }); } } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } let (pts, rot) = box_handles(&x); if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { return Some(Hit::Rotate { id: *id }); @@ -345,6 +412,8 @@ impl Session { return Ok(Value::Null); } self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); if line_tool { self.update_sites(p, tol, None); @@ -442,7 +511,7 @@ impl Session { { 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 { .. })) + && !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; @@ -515,6 +584,18 @@ impl Session { self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Warp { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let found = st.shape(id).and_then(|sh| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -554,6 +635,14 @@ impl Session { let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); } } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } None => { let add = mods.shift || mods.cmd; self.select(|_, sel| { @@ -573,6 +662,13 @@ impl Session { fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { @@ -717,6 +813,23 @@ impl Session { } return Ok(Value::Null); } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } @@ -805,6 +918,18 @@ impl Session { self.tool.glue = None; let mut out = Value::Null; match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { // Plain click on text: caret at the click. let st = self.doc()?; @@ -1013,7 +1138,8 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Warp { id, .. }, ) => Some(*id), None => None, }; @@ -1148,6 +1274,22 @@ impl Session { }) } + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { @@ -1166,6 +1308,15 @@ impl Session { (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), (size.height, Rect::new(0.0, size.height, size.width, size.height)), ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } for sh in st.shapes() { if exclude.contains(&sh.id) || sh.hidden { continue; diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 17575461..d3035d51 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -286,6 +286,194 @@ pub const CATALOG: &[Preset] = &[ Preset { id: "textCascadeDown", label: "Cascade Down" }, ]; +/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect` +/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the +/// text rectangle's centre for an angle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpHandle { + pub index: usize, + pub pos: Point, + kind: HandleKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HandleKind { + /// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps. + Line { origin: Point, dir: Vec2, min: f64, max: f64 }, + /// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`. + Angle { center: Point, invert: bool }, +} + +impl WarpHandle { + /// The raw guide value (`adj` / `adjN`) for a pointer at `p`. + pub fn value_at(&self, p: Point) -> f64 { + match self.kind { + HandleKind::Line { origin, dir, min, max } => { + let l2 = dir.hypot2(); + if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) { + return min; + } + let v = (p - origin).dot(dir) / l2; + if !v.is_finite() { + return min; + } + let (lo, hi) = (min.min(max), max.max(min)); + v.clamp(lo, hi).round() + } + HandleKind::Angle { center, invert } => { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return 0.0; + } + let mut deg = d.y.atan2(d.x).to_degrees(); + if invert { + deg = 180.0 - deg; + } + (deg.rem_euclid(360.0) * 60_000.0).round() + } + } + } +} + +fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 { + adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) +} + +fn unit(rect: Rect, u: f64, v: f64) -> Point { + Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height()) +} + +/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`. +fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, x, sign * frac), + kind: HandleKind::Line { + origin: unit(rect, x, 0.0), + dir: Vec2::new(0.0, sign * rect.height() / 100_000.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// A fraction handle that slides horizontally: unit `(frac, y)`. +fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, frac, y), + kind: HandleKind::Line { + origin: unit(rect, 0.0, y), + dir: Vec2::new(rect.width() / 100_000.0, 0.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle. +fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle { + let geom = if invert { 180.0 - deg } else { deg }; + let rad = geom.to_radians(); + let c = rect.center(); + WarpHandle { + index, + pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()), + kind: HandleKind::Angle { center: c, invert }, + } +} + +/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse. +fn radius_handle(rect: Rect, r: f64) -> WarpHandle { + let r = r.clamp(0.0, 0.99); + WarpHandle { + index: 1, + pos: unit(rect, 0.5, 0.5 + 0.5 * r), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 }, + } +} + +/// Wave phase: slides sideways around the middle of the lower half. +fn shift_handle(rect: Rect, s: f64) -> WarpHandle { + let s = s.clamp(-0.1, 0.1); + WarpHandle { + index: 1, + pos: unit(rect, 0.5 + s, 0.78), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 }, + } +} + +/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn +/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults. +pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + if rect.width() < 1e-3 || rect.height() < 1e-3 { + return Vec::new(); + } + let defaults = adjust_defaults(name); + if defaults.is_empty() { + return Vec::new(); + } + let raw = |i| stored_raw(adj, defaults, i); + let frac = |i| raw(i) / 100_000.0; + let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0); + let y = |out: &mut Vec, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign)); + let xh = |out: &mut Vec, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi)); + let mut out = Vec::with_capacity(defaults.len()); + match name { + "textPlain" => xh(&mut out, 0.28, 0.3, 0.7), + "textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0), + "textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0), + "textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0), + "textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0), + "textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0), + "textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999), + "textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999), + "textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0), + "textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0), + "textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0), + "textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0), + "textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0), + "textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0), + "textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0), + "textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0), + "textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0), + "textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0), + "textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0), + "textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0), + "textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0), + "textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)), + "textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)), + "textArchUpPour" | "textCirclePour" | "textButtonPour" => { + out.push(angle_handle(rect, 0, deg(0), false)); + out.push(radius_handle(rect, frac(1))); + } + "textArchDownPour" => { + out.push(angle_handle(rect, 0, deg(0), true)); + out.push(radius_handle(rect, frac(1))); + } + "textWave1" | "textWave2" => { + y(&mut out, 0.32, 0.0, 0.2, 1.0); + out.push(shift_handle(rect, frac(1))); + } + "textDoubleWave1" | "textWave4" => { + y(&mut out, 0.32, 0.0, 0.125, 1.0); + out.push(shift_handle(rect, frac(1))); + } + _ => return Vec::new(), + } + out +} + /// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). #[derive(Clone, Copy, Debug)] enum Seg { diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index 7a2c3a14..c04ddd10 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -277,3 +277,43 @@ fn hostile_adjusts_and_boxes_stay_finite() { } assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); } + +#[test] +fn every_preset_has_one_handle_per_adjust() { + let r = rect(); + for name in CATALOG.iter().map(|p| p.id) { + let hs = handles(name, &[], r); + assert_eq!(hs.len(), adjust_count(name), "{name}"); + let defaults = adjust_defaults(name); + for h in &hs { + assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}"); + let back = h.value_at(h.pos); + let want = defaults[h.index]; + assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index); + } + } + assert!(handles("textNoShape", &[], r).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty()); + // A hostile adjust still places a finite handle on the clamped default. + let hs = handles("textInflate", &[f64::NAN], r); + assert_eq!(hs.len(), 1); + assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite()); +} + +#[test] +fn arch_and_inflate_handles_follow_the_pointer() { + let r = rect(); + let arch = &handles("textArchUp", &[], r)[0]; + let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0; + assert!((deg - 270.0).abs() < 0.5, "{deg}"); + let down = &handles("textArchDown", &[], r)[0]; + // The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90 + // (guide angle 180 − adj). + let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0; + assert!((deg - 90.0).abs() < 0.5, "{deg}"); + let inf = &handles("textInflate", &[], r)[0]; + let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0)); + assert!(more > adjust_defaults("textInflate")[0], "{more}"); + assert!((more - 20_000.0).abs() < 1.0, "{more}"); +} diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 5507b23a..5300de8b 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -515,6 +515,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen table_styles: vec![], tags: vec![], kept_parts: vec![], + guides: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 82c9100e..f9fbca0b 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -974,11 +974,23 @@ pub struct Presentation { /// sensitivity label and custom document properties. #[serde(skip_serializing_if = "Vec::is_empty")] pub kept_parts: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + impl Default for Presentation { fn default() -> Self { defaults::new_presentation(None) diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 5f0f3747..5f2ad912 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -578,6 +578,20 @@ pub fn import(bytes: &[u8]) -> Result { p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); } } + // Drawing guides (eighths of a point). + if let Some(r) = pres_part.rels.first_of("viewProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) + { + p.guides = lst + .children_named("guide") + .filter_map(|g| { + let pos = g.attr("pos").and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 8.0; + pos.is_finite().then_some(deckcraft_model::DrawGuide { vertical: g.attr("orient") != Some("horz"), pos }) + }) + .take(1000) + .collect(); + } // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 45689d0e..ed09ca69 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -687,7 +687,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { pres.rels.add("viewProps", "viewProps.xml", false); pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); - x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); + x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml(p)); let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), handout_rid.as_deref(), &slide_rids); @@ -1155,13 +1155,34 @@ fn pres_props_xml(p: &Presentation) -> Vec { w.finish() } -fn view_props_xml() -> Vec { +fn view_props_xml(p: &Presentation) -> Vec { let mut w = W::new(); root_open(&mut w, "p:viewPr", A::new()); w.open0("p:normalViewPr"); w.empty("p:restoredLeft", A::new().a("sz", 15620)); w.empty("p:restoredTop", A::new().a("sz", 94660)); w.close("p:normalViewPr"); + if !p.guides.is_empty() { + // Drawing guides; `pos` is in eighths of a point (576 per inch). + w.open0("p:slideViewPr"); + w.open0("p:cSldViewPr"); + w.open0("p:cViewPr"); + w.open0("p:scale"); + w.empty("a:sx", A::new().a("n", 100).a("d", 100)); + w.empty("a:sy", A::new().a("n", 100).a("d", 100)); + w.close("p:scale"); + w.empty("p:origin", A::new().a("x", 0).a("y", 0)); + w.close("p:cViewPr"); + w.open0("p:guideLst"); + for g in p.guides.iter().take(1000) { + let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64; + let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) }; + w.empty("p:guide", a); + } + w.close("p:guideLst"); + w.close("p:cSldViewPr"); + w.close("p:slideViewPr"); + } w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200)); w.close("p:viewPr"); w.finish() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d5045f07..12c89f30 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -241,32 +241,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { painter.extend(dash); } } - if app.ui.gridlines { - let step = 72.0 / 2.0 * scale; - let mut x = slide_rect.min.x + step; - while x < slide_rect.max.x { - painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - x += step; - } - let mut y = slide_rect.min.y + step; - while y < slide_rect.max.y { - painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - y += step; - } + if app.ui.gridlines + && let Some(step) = grid_line_step(app.session.prefs.grid_spacing, scale) + { + draw_grid(&painter, slide_rect, step); } + app.session.tool.guides_visible = app.ui.guides; if app.ui.guides { - let c = slide_rect.center(); - painter.line_segment( - [pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); - painter.line_segment( - [pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); + draw_guides(app, &painter, xf, slide_rect, &t); } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); + ruler_markers(app, ui, &painter, avail, xf, &t); } pointer(app, ui, &resp, xf); if !app.anim_preview_active() { @@ -485,6 +471,236 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag()); + let id = ui.id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); + } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } +} + +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { if crate::eyedropper::canvas(app, ui, resp, xf) { return; @@ -500,6 +716,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } let p = xf.to_slide(pos); let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; let primary = ui.input(|i| i.pointer.primary_pressed()); @@ -538,7 +759,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { egui::CursorIcon::Text @@ -547,7 +768,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { } } (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, - _ => egui::CursorIcon::Default, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, }; if resp.hovered() { ui.ctx().set_cursor_icon(cursor); @@ -655,6 +880,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let q = xf.to_screen(a * hd.pos); painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } } } // Caret / text selection. @@ -955,6 +1184,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("shape.editPoints", json!({"on": true})); ui.close(); } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); @@ -1006,6 +1243,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); if ui.button("Reset Slide").clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); @@ -1022,6 +1272,42 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { }); } +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -1113,4 +1399,24 @@ mod tests { h.run_steps(2); assert_near(mid, slide_point(&h, c), "zoom out"); } + + #[test] + fn grid_step_follows_spacing_and_rejects_unusable_values() { + let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); + assert!((step - 36.0).abs() < 1e-3, "{step}"); + assert!(super::grid_line_step(6.0, 1.0).is_some()); + assert!(super::grid_line_step(0.0, 1.0).is_none()); + assert!(super::grid_line_step(f64::NAN, 1.0).is_none()); + assert!(super::grid_line_step(1_000.0, 1.0).is_none()); + assert!(super::grid_line_step(6.0, 0.0).is_none()); + assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); + } } + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/ui-egui/src/compare_tests.rs b/crates/ui-egui/src/compare_tests.rs new file mode 100644 index 00000000..9108b4de --- /dev/null +++ b/crates/ui-egui/src/compare_tests.rs @@ -0,0 +1,61 @@ +//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide. +fn harness() -> (Harness<'static, SlideApp>, usize) { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": n - 2})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, n) +} + +#[test] +fn pane_opens_lists_changes_and_accept_all_applies_them() { + let (mut h, n) = harness(); + assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane"); + assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some()); + assert!(h.query_by_label("Slide 2 deleted").is_some()); + assert!(h.query_by_label_contains("Slide inserted after").is_some()); + h.get_by_label_contains("Accept All").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added"); + assert!(h.query_by_label("No differences left.").is_some()); + h.get_by_label("End Review").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().compare.is_none()); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let (mut h, _) = harness(); + // Review tab. + h.state_mut().ui.tab = "Review".into(); + h.run_steps(4); + h.render().unwrap().save("/tmp/compare.png").unwrap(); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9ca9839a..4829e004 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Paragraph, Tabs, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). use deckcraft_color::SchemeSlot; @@ -45,9 +45,12 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", + "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", "autocorrect" => "AutoCorrect", + "gridOptions" => "Grid Options", "export" => "Export", "symbol" => "Symbol", "photoAlbum" => "Photo Album", @@ -56,6 +59,7 @@ fn title(id: &str) -> &'static str { "dateTime" => "Date and Time", "spelling" => "Spelling", "pasteSpecial" => "Paste Special", + "find" => "Find and Replace", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", @@ -101,8 +105,17 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { } close = body(app, ui, &mut d); }); - if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if open && !close && !escape { app.dialog = Some(d); + } else if app.dialog.is_none() + && let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned() + { + // Tabs… opened from Paragraph: closing it (OK, Cancel, Esc or the window) returns there. + let id = d.params.get("returnId").and_then(Value::as_str).unwrap_or("paragraph"); + let mut back = Dialog::new(id); + back.params = ret; + app.dialog = Some(back); } } @@ -1495,22 +1508,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - "paragraph" => { - field(ui, d, "Before text (in):", "left", "0"); - field(ui, d, "Special — hanging (in):", "hang", "0"); - field(ui, d, "Spacing before (pt):", "before", "0"); - field(ui, d, "Spacing after (pt):", "after", "0"); - field(ui, d, "Line spacing (lines):", "lines", "1.0"); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "format.paragraph", - json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}), - ); - } - ok || cancel - } + "font" => font_dialog(app, ui, d), + "paragraph" => paragraph_dialog(app, ui, d), + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -1689,6 +1689,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "find" => find_dialog(app, ui, d), "spelling" => { // Words skipped with Ignore All while the dialog is open, one per line. let ignored = d.get("ignore", ""); @@ -2084,6 +2085,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, "OK"); ok || cancel } + "gridOptions" => grid_options(app, ui, d), "preferences" => { ui.label(egui::RichText::new("General").font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; @@ -2456,6 +2458,43 @@ fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { cancel } +fn format_spacing(v: f64) -> String { + if !v.is_finite() { + return "6".into(); + } + if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } +} + +/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one +/// `view.gridOptions` call; Cancel leaves the presentation as it was. +fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); + let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); + let spacing_default = format_spacing(app.session.prefs.grid_spacing); + field(ui, d, "Spacing (pt):", "spacing", &spacing_default); + ui.add_space(6.0); + ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); + let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let spacing = num(d, "spacing", app.session.prefs.grid_spacing); + match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { + Ok(_) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} + fn pretty_equation(s: &str) -> String { let sup = |c: char| match c { '0' => '⁰', @@ -2575,6 +2614,589 @@ pub fn selected_language(app: &SlideApp) -> (String, bool) { (props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true)) } +fn seed_once(d: &mut Dialog, key: &str, val: String) { + d.fields.entry(key.to_string()).or_insert(val); +} + +fn json_text(v: Option<&Value>, default: &str) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()), + Some(Value::Bool(true)) => "1".into(), + Some(Value::Bool(false)) => "0".into(), + _ => default.to_string(), + } +} + +fn color_label(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(), + _ => String::new(), + } +} + +fn seed_font_fields(d: &mut Dialog) { + seed_once(d, "family", json_text(d.params.get("family"), "")); + seed_once(d, "size", json_text(d.params.get("size"), "18")); + seed_once(d, "bold", json_text(d.params.get("bold"), "0")); + seed_once(d, "italic", json_text(d.params.get("italic"), "0")); + seed_once(d, "underline", json_text(d.params.get("underline"), "none")); + seed_once(d, "strike", json_text(d.params.get("strike"), "none")); + seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); + seed_once(d, "caps", json_text(d.params.get("caps"), "none")); + seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); + let shown = d.params.get("color").map(color_label).unwrap_or_default(); + seed_once(d, "color", shown.clone()); + seed_once(d, "colorSeed", shown); +} + +fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, ""); + let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(cur == *v, *l).clicked() { + d.fields.insert((*key).into(), (*v).into()); + } + } + }); +} + +fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { + let mut v = d.get(key, ""); + let l = ui.label(label); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id); + d.fields.insert(key.into(), v.clone()); + v +} + +fn finite_pt(text: &str) -> Option { + text.trim().trim_end_matches("pt").trim().parse::().ok().filter(|v| v.is_finite()) +} + +/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. +fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_font_fields(d); + let mut fams = crate::ribbon::font_families(app); + let family = d.get("family", ""); + if !family.is_empty() && !fams.iter().any(|f| f == &family) { + fams.insert(0, family.clone()); + } + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui( + ui, + |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(family == *f, f).clicked() { + d.fields.insert("family".into(), f.clone()); + } + } + }); + }, + ); + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Size", "size", 64.0); + ui.add_space(16.0); + check(ui, d, "Bold", "bold", false); + check(ui, d, "Italic", "italic", false); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + choice( + ui, + d, + "font_dialog_underline", + "underline", + &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], + ); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "Color", "color", 110.0); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + ui.add_space(8.0); + ui.label("Caps"); + choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + let mut spacing = d.get("spacing", "0"); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let mut picked = None; + egui::ComboBox::from_id_salt("font_dialog_spacing") + .selected_text(match spacing.as_str() { + "-3" => "Very tight", + "-1.5" => "Tight", + "0" => "Normal", + "3" => "Loose", + "6" => "Very loose", + other => other, + }) + .width(120.0) + .show_ui(ui, |ui| { + for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { + if ui.selectable_label(spacing == v, l).clicked() { + picked = Some(v.to_string()); + } + } + }); + let _ = edited; + if let Some(v) = picked { + d.fields.insert("spacing".into(), v); + } else { + d.fields.insert("spacing".into(), spacing); + } + }); + let bold = d.get("bold", "0") == "1"; + let italic = d.get("italic", "0") == "1"; + let underline = d.get("underline", "none"); + let strike = d.get("strike", "none"); + let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; + let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + if bold { + sample = sample.strong(); + } + if italic { + sample = sample.italics(); + } + if underline != "none" { + sample = sample.underline(); + } + if strike != "none" { + sample = sample.strikethrough(); + } + ui.add_space(6.0); + ui.label(sample); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let family = d.get("family", ""); + let family = family.trim(); + if family.is_empty() { + app.set_status("Choose a font"); + return false; + } + let Some(size) = finite_pt(&d.get("size", "")) else { + app.set_status("Font size must be a number"); + return false; + }; + let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { + app.set_status("Character spacing must be a number"); + return false; + }; + let mut params = json!({ + "family": family, + "size": size, + "bold": d.get("bold", "0") == "1", + "italic": d.get("italic", "0") == "1", + "underline": d.get("underline", "none"), + "strike": d.get("strike", "none"), + "baseline": d.get("baseline", "none"), + "caps": d.get("caps", "none"), + "spacing": spacing, + }); + let color = d.get("color", ""); + let seed = d.get("colorSeed", ""); + if color == seed { + if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { + params["color"] = c.clone(); + } + } else if !color.trim().is_empty() { + params["color"] = json!(color.trim()); + } + let _ = app.run("format.fontDialog", params); + } + ok || cancel +} + +fn pt_text(v: Option<&Value>, default: f64) -> String { + match v.and_then(Value::as_f64).filter(|n| n.is_finite()) { + Some(n) => crate::widgets::fmt_num(n, ""), + None => crate::widgets::fmt_num(default, ""), + } +} + +fn inches_text(v: Option<&Value>) -> String { + crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "") +} + +fn seed_paragraph_fields(d: &mut Dialog) { + seed_once(d, "align", json_text(d.params.get("align"), "left")); + seed_once(d, "before", inches_text(d.params.get("indentLeft"))); + seed_once(d, "after", inches_text(d.params.get("indentRight"))); + seed_once(d, "special", json_text(d.params.get("special"), "none")); + seed_once(d, "by", inches_text(d.params.get("specialBy"))); + seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); + seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); +} + +fn paragraph_return_params(d: &mut Dialog) -> Value { + let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; + let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; + let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; + let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); + let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); + let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); + let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); + let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); + let v = json!({ + "align": d.get("align", "left"), + "indentLeft": before, + "indentRight": after, + "special": d.get("special", "none"), + "specialBy": by, + "spaceBefore": space_before, + "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfter": space_after, + "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "lineKind": d.get("lineKind", "multiple"), + "lineValue": line_value, + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + }); + v +} + +fn line_preset_label(kind: &str, value: &str) -> &'static str { + if kind == "exactly" { + "Exactly" + } else { + match value { + "1" => "Single", + "1.5" => "1.5 lines", + "2" => "Double", + _ => "Multiple", + } + } +} + +/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. +fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_paragraph_fields(d); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + choice( + ui, + d, + "paragraph_align", + "align", + &[ + ("left", "Left"), + ("center", "Center"), + ("right", "Right"), + ("justify", "Justify"), + ("justLow", "Justify low"), + ("distributed", "Distributed"), + ], + ); + }); + ui.label(egui::RichText::new("Indentation").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Before text", "before", 64.0); + ui.label("in"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "After text", "after", 64.0); + ui.label("in"); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "By", "by", 64.0); + ui.label("in"); + }); + ui.label(egui::RichText::new("Spacing").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); + ui.label("pt"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); + ui.label("pt"); + }); + let mut picked: Option<(&'static str, Option<&'static str>)> = None; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let kind = d.get("lineKind", "multiple"); + let value = d.get("lineValue", "1"); + egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } + } + }); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "At", "lineValue", 64.0); + }); + if let Some((kind, value)) = picked { + d.fields.insert("lineKind".into(), kind.into()); + if let Some(v) = value { + d.fields.insert("lineValue".into(), v.into()); + } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { + d.fields.insert("lineValue".into(), "18".into()); + } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { + d.fields.insert("lineValue".into(), "1".into()); + } + } + let mut open_tabs = false; + ui.horizontal(|ui| { + if ui.button("Tabs…").clicked() { + open_tabs = true; + } + }); + let (ok, cancel) = buttons(ui, "OK"); + if open_tabs { + let mut tabs = Dialog::new("tabs"); + tabs.params = json!({ + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + "returnId": "paragraph", + "returnParams": paragraph_return_params(d), + }); + app.dialog = Some(tabs); + return true; + } + if ok { + let Some(before_in) = finite_pt(&d.get("before", "")) else { + app.set_status("Indent before text must be a number of inches"); + return false; + }; + let Some(after_in) = finite_pt(&d.get("after", "")) else { + app.set_status("Indent after text must be a number of inches"); + return false; + }; + let Some(by_in) = finite_pt(&d.get("by", "")) else { + app.set_status("Special indent must be a number of inches"); + return false; + }; + let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { + app.set_status("Spacing before must be a number"); + return false; + }; + let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { + app.set_status("Spacing after must be a number"); + return false; + }; + let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { + app.set_status("Line spacing must be a number"); + return false; + }; + let mut params = json!({ + "align": d.get("align", "left"), + "indentLeft": before_in * 72.0, + "indentRight": after_in * 72.0, + "special": d.get("special", "none"), + "specialBy": by_in * 72.0, + "spaceBefore": space_before, + "spaceAfter": space_after, + "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + }); + if d.get("lineKind", "multiple") == "exactly" { + params["lineSpacingPt"] = json!(line_value); + } else { + params["lineSpacing"] = json!(line_value); + } + if app.run("format.paragraphDialog", params).is_err() { + return false; + } + } + ok || cancel +} + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); + } + } + ok || cancel +} + +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + field(ui, d, "Find what:", "text", ""); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} + +#[cfg(test)] +#[path = "font_dialog_tests.rs"] +mod font_dialog_tests; + #[cfg(test)] #[path = "size_position_tests.rs"] mod size_position_tests; @@ -2818,3 +3440,7 @@ mod trim_media_tests { #[cfg(test)] #[path = "spelling_tests.rs"] mod spelling_tests; + +#[cfg(test)] +#[path = "paragraph_dialog_tests.rs"] +mod paragraph_dialog_tests; diff --git a/crates/ui-egui/src/font_dialog_tests.rs b/crates/ui-egui/src/font_dialog_tests.rs new file mode 100644 index 00000000..06ccfc02 --- /dev/null +++ b/crates/ui-egui/src/font_dialog_tests.rs @@ -0,0 +1,65 @@ +//! The Font dialog: Home › Font › Font… and Cmd+T open it, OK applies one undo step, Cancel does not. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + // A selected shape, not a caret: the dialog then formats the text that is already there. + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn bold_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.bold) +} + +#[test] +fn font_button_applies_bold_from_the_dialog() { + let mut h = harness(); + h.get_by_label("Font…").click(); + // The opening frame ignores the click that summoned the window; the next one is live. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font")); + // A pointer click on this checkbox does not register through kittest's per-event frames; + // the accessibility click does, and it is the same toggle a pointer click runs. + h.get_by_label("Bold").click_accesskit(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(bold_of(&h), Some(true)); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Font…"); +} + +#[test] +fn command_t_opens_the_font_dialog_and_cancel_keeps_the_text() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T); + h.run_steps(1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"), "status {:?}", h.state().status); + h.get_by_label("Cancel").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(bold_of(&h), None); +} diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/guides_tests.rs new file mode 100644 index 00000000..4180e503 --- /dev/null +++ b/crates/ui-egui/src/guides_tests.rs @@ -0,0 +1,69 @@ +//! Drawing guides driven with the mouse: View › Guides adds the centre guides, dragging one +//! moves it (with its distance from the centre shown), dropping it off the slide deletes it. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn turn_guides_on_drag_one_and_drop_it_off_the_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().run("view.guides", json!({"on": true})).unwrap(); + h.run_steps(2); + let guides = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.guides.clone(); + assert_eq!(guides(&h).len(), 2, "the two centre guides"); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + // Grab the vertical guide away from the horizontal one and drag it left. + let start = at(480.0, 100.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(480.0 - 18.0 * i as f32, 100.0))); + h.run_steps(1); + } + assert_eq!(h.state().session.tool.guide_drag, Some((0, 300.0))); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/guides.png"); + } + h.event(egui::Event::PointerButton { pos: at(300.0, 100.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h)[0].pos, 300.0); + + // Off the top of the slide with the horizontal guide: deleted. + let g = at(700.0, 270.0); + h.hover_at(g); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: g, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(700.0, 270.0 - 30.0 * i as f32))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: at(700.0, -30.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h).len(), 1); + assert!(guides(&h)[0].vertical); + + // Hidden guides stay in the file but aren't drawn or grabbed. + h.state_mut().run("view.guides", json!({"on": false})).unwrap(); + h.run_steps(2); + assert!(!h.state().session.tool.guides_visible); + assert_eq!(guides(&h).len(), 1); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0944d5..cb8f12ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -357,12 +357,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.ruler", "Ruler", None, "{on?: bool}"), ("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"), ("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"), + ("view.gridOptions", "Grid Options…", None, "{spacing?: pt, snap?: bool, smartGuides?: bool, gridlines?: bool, guides?: bool}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Set Zoom", None, "{percent}"), ("view.zoomDialog", "Zoom…", None, "{}"), ("view.fit", "Fit to Window", None, "{}"), ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|smartart|null}"), + ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), @@ -392,9 +394,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), ("app.browseThemeDialog", "Browse for Themes…", None, "{}"), + ("app.compareDialog", "Compare…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), ("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"), ("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"), + ("app.find", "Find…", Some("Cmd+F"), "{}"), + ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -427,6 +432,8 @@ pub struct SlideApp { /// Spelling squiggles, checked on a worker thread. pub squiggles: squiggles::Squiggles, pub dialog: Option, + /// The word looked up in the Thesaurus pane. + pub thesaurus_query: String, pub show: Option, /// Current slide's animations playing on the editor canvas (not the full-screen show). anim_preview: Option, @@ -490,6 +497,18 @@ pub struct SlideApp { pending_files: Vec, } +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + impl SlideApp { pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); @@ -502,6 +521,7 @@ impl SlideApp { control_rx: None, squiggles: Default::default(), dialog: None, + thesaurus_query: String::new(), show: None, anim_preview: None, media: media::MediaHost::new(audio_out), @@ -745,7 +765,60 @@ impl SlideApp { "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; @@ -762,6 +835,19 @@ impl SlideApp { } "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } "view.pane" => { let pane = p.get("pane").and_then(Value::as_str).map(String::from); self.ui.pane = @@ -863,6 +949,23 @@ impl SlideApp { } } "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -1756,6 +1859,8 @@ mod animpane_tests; #[cfg(test)] mod browsetheme_tests; #[cfg(test)] +mod compare_tests; +#[cfg(test)] mod eyedropper_tests; #[cfg(test)] mod huge_deck_tests; @@ -2088,6 +2193,79 @@ mod tests { assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); assert!(app.session.active().is_none()); } + + #[test] + fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + } + + #[test] + fn grid_options_dialog_lists_spacing_and_display() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + } + fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c6f1e640..440fd939 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -70,10 +70,8 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); return true; } - (F, true, false, false) => { - app.palette = Some((String::new(), 0)); - return true; - } + (F, true, false, false) => "app.find", + (H, true, false, false) => "app.replace", _ => return false, }; let _ = app.run(id, json!({})); @@ -193,7 +191,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Duplicate", "edit.duplicate"), ("Delete Slide", "slide.delete"), ("-", ""), - ("Find…", "app.palette"), + ("Find…", "app.find"), + ("Replace…", "app.replace"), ], ), ( @@ -213,6 +212,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), ("Guides", "view.guides"), + ("Grid Options…", "view.gridOptions"), ("-", ""), ("Zoom…", "view.zoomDialog"), ("Zoom In", "view.zoomIn"), @@ -246,6 +246,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ( "Format", vec![ + ("Font…", "format.fontDialog"), + ("Paragraph…", "format.paragraphDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c0d5f570..e2a5a78e 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -29,6 +29,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "comments" => "Comments", "designer" => "Design Ideas", "smartart" => "Text Pane", + "thesaurus" => "Thesaurus", + "revisions" => "Revisions", _ => "Pane", }; ui.horizontal(|ui| { @@ -53,6 +55,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "comments" => comments(app, ui), "designer" => designer(app, ui), "smartart" => smartart(app, ui), + "thesaurus" => thesaurus(app, ui), + "revisions" => revisions(app, ui), _ => {} }); }); @@ -446,9 +450,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Pattern(_)) => "pattern", _ => "auto", }; - for (l, k) in - [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] - { + for (l, k) in [ + ("No fill", "none"), + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Automatic", "auto"), + ] { if ui.radio(kind == k, l).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), @@ -459,6 +468,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), ), "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + "picture" => pick_fill_picture(app), _ => run(app, "shape.fill", json!({"reset": true})), } } @@ -484,6 +494,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, v)); } }); + if let Some(Fill::Picture(pf)) = &sh.fill { + picture_fill_options(app, ui, sh.id.0, pf); + } if let Some(Fill::Gradient(g)) = &sh.fill { let sc = scheme(app); if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { @@ -624,6 +637,95 @@ fn fx_slider( }); } +/// Asks for a picture file and makes it the selected shapes' fill. +fn pick_fill_picture(app: &mut SlideApp) { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } +} + +/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` +/// when it changes. +fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); + } + }); +} + +/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. +fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + ui.horizontal(|ui| { + ui.label("Picture source"); + if ui.button("Insert…").clicked() { + pick_fill_picture(app); + } + }); + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); + } + match &pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); + pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); + pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); + pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); + let aligns = [ + ("tl", "Top left"), + ("t", "Top"), + ("tr", "Top right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom left"), + ("b", "Bottom"), + ("br", "Bottom right"), + ]; + let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; + for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); + egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in opts { + if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + run(app, "shape.fill", json!({"pictureProps": {key: k}})); + } + } + }); + }); + } + } + PictureMode::Stretch { fill_rect } => { + for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = fill_rect[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let mut rect = *fill_rect; + rect[i] = v / 100.0; + run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); + } + }); + } + } + } + let mut rot = !pf.upright; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); + } +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); @@ -899,6 +1001,61 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = paint_anim_tile; } +/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it. +fn revisions(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("review.revisions", &json!({})) else { + ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + return; + }; + let changes = v["changes"].as_array().cloned().unwrap_or_default(); + ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + if v["byPosition"].as_bool() == Some(true) { + ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + } + ui.add_space(4.0); + if changes.is_empty() { + ui.label("No differences left."); + if ui.button("End Review").clicked() { + run(app, "review.endCompare", json!({})); + app.ui.pane = None; + } + return; + } + if ui.button(format!("Accept All ({})", changes.len())).clicked() { + run(app, "review.accept", json!({"all": true})); + } + ui.separator(); + for (i, c) in changes.iter().enumerate() { + let slide = c["slide"].as_u64().unwrap_or(0); + let (head, col) = match c["kind"].as_str().unwrap_or("") { + "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + }; + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(2), col); + if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { + let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); + run(app, "slide.go", json!({"index": (slide as usize).min(max)})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Accept").clicked() { + run(app, "review.accept", json!({"index": i})); + } + }); + }); + for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { + ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); + } + }); + ui.add_space(4.0); + } +} + fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { @@ -1206,3 +1363,42 @@ mod tests { assert_eq!(solid_alpha(h.state(), b), Some(60_000)); } } + +/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word +/// when editing text, otherwise looks that synonym up in turn. +fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let mut q = app.thesaurus_query.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + if edit.changed() { + app.thesaurus_query = q.clone(); + } + ui.add_space(6.0); + let senses = deckcraft_engine::thesaurus::lookup(&q); + if q.trim().is_empty() { + ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + return; + } + if senses.is_empty() { + ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + return; + } + let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); + for sense in senses { + ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); + for syn in sense.synonyms { + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + if r.clicked() { + if editing { + run(app, "review.replaceWord", json!({"with": syn})); + } + app.thesaurus_query = syn; + } + } + ui.add_space(6.0); + } +} + +#[cfg(test)] +#[path = "thesaurus_tests.rs"] +mod thesaurus_tests; diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs new file mode 100644 index 00000000..fb8e796b --- /dev/null +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -0,0 +1,87 @@ +//! The Paragraph dialog: Home › Paragraph › Paragraph… opens it, OK applies one undo step, +//! and Tabs… edits stops then returns to Paragraph. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn align_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align.map(|a| a.xml().to_string())) +} + +fn tabs_of(h: &Harness<'_, SlideApp>) -> Vec<(f64, String)> { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.tabs.clone()) + .unwrap_or_default() + .into_iter() + .map(|t| (t.pos, t.align)) + .collect() +} + +#[test] +fn paragraph_button_aligns_text_in_one_undo() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph")); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("OK").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(align_of(&h).as_deref(), Some("r")); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); +} + +#[test] +fn tabs_from_paragraph_applies_a_stop_and_returns() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("Tabs…").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("stops".into(), "2:ctr".into()); + } + h.get_by_label("OK").click_accesskit(); + // The Tabs OK frame restores Paragraph; the next frame fills its fields. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status); + assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right")); + assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…"); + h.get_by_label("Cancel").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); +} diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index cec694bf..205c65aa 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -50,7 +50,8 @@ fn ribbon_button_opens_the_dialog_and_replaces() { d.fields.insert("to".into(), "Lato".into()); } h.run_steps(2); - h.get_by_label("Replace").click(); + // The ribbon has a Replace button too (Find and Replace); the dialog's is drawn last. + h.query_all_by_label("Replace").last().expect("the dialog's Replace button").click(); h.run_steps(3); assert_eq!(run_font(&h).as_deref(), Some("Lato")); // The dialog stays open with a confirmation, like PowerPoint's. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 45d68bf5..2ab6dfcf 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -563,6 +563,9 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } }); }); }); @@ -742,7 +745,10 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } if ui.button("Line Spacing Options…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + run(app, "format.paragraphDialog", json!({})); + } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); @@ -785,6 +791,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); + if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + run(app, "format.paragraphDialog", json!({})); + } }); }); }); @@ -1150,12 +1159,17 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } + }); // Also under Arrange, but that is disabled until something is selected (#132). big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); @@ -2115,8 +2129,7 @@ fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { fn review(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); - let _ = r.on_hover_text("Thesaurus (coming soon)"); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); }); group(ui, |ui| { big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); @@ -2126,6 +2139,19 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { let _ = r.on_hover_text("Translation needs a language model (not bundled)"); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); group(ui, |ui| { big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); rows(ui, |ui| { @@ -2185,6 +2211,9 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { app.ui.guides = gd; } }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } let n = app.ui.notes; if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { app.ui.notes = !n; diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs new file mode 100644 index 00000000..7c313563 --- /dev/null +++ b/crates/ui-egui/src/ruler_tests.rs @@ -0,0 +1,160 @@ +//! The ruler's indent and tab markers driven with the mouse while editing text. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=8 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, SlideApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value { + h.state_mut().session.execute("format.ruler", &json!({})).unwrap() +} + +#[test] +fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Name\tRole\tTeam"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + // The horizontal ruler runs along the top of the canvas. + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + + // First-line indent triangle (top) to 36 pt. + drag(&mut h, Pos2::new(rx(0.0), top + 3.0), Pos2::new(rx(36.0), top + 3.0)); + let r = ruler(&mut h); + assert!((r["indent"].as_f64().unwrap() - 36.0).abs() <= 1.0, "{r}"); + assert_eq!(r["marginLeft"].as_f64(), Some(0.0)); + + // Hanging triangle (middle) to 18 pt: the first line stays at 36. + drag(&mut h, Pos2::new(rx(0.0), top + 8.0), Pos2::new(rx(18.0), top + 8.0)); + let r = ruler(&mut h); + let (ml, ind) = (r["marginLeft"].as_f64().unwrap(), r["indent"].as_f64().unwrap()); + assert!((ml - 18.0).abs() <= 1.0 && (ml + ind - 36.0).abs() <= 1.0, "{r}"); + + // Click on the ruler: a left tab; drag it along; drag it off to remove it. + click(&mut h, Pos2::new(rx(150.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + let pos = r["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 150.0).abs() <= 1.0); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(220.0), top + 12.0)); + let pos = ruler(&mut h)["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 220.0).abs() <= 1.0, "{pos}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler.png"); + } + assert!(h.state().session.active().unwrap().selection.text.is_some(), "still editing the text"); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); + assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); +} + +fn editing_harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Item\t9.50"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + let corner = h.state().canvas_rect.unwrap().min + egui::vec2(8.0, 8.0); + assert!(h.query_by_label("Left Tab").is_some()); + for _ in 0..3 { + click(&mut h, corner); + } + assert!(h.query_by_label("Decimal Tab").is_some()); + click(&mut h, Pos2::new(rx(200.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"][0]["align"], "dec", "{r}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler_decimal.png"); + } +} + +#[test] +fn tabs_dialog_sets_clears_and_applies_on_ok() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("tabs")); + h.run_steps(3); + assert!(h.query_by_label_contains("0.50\"").is_some(), "the existing stop is listed"); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("newpos".into(), "2".into()); + d.fields.insert("default".into(), "0.5".into()); + h.run_steps(1); + h.get_by_label("Center").click(); + h.run_steps(2); + h.get_by_label("Set").click(); + h.run_steps(2); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/tabs_dialog.png"); + } + // Nothing changes until OK. + assert_eq!(ruler(&mut h)["tabs"].as_array().unwrap().len(), 1); + h.query_all_by_label("Clear").next().expect("a Clear button per stop").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!((r["tabs"][0]["pos"].as_f64(), r["tabs"][0]["align"].as_str()), (Some(144.0), Some("ctr"))); + assert_eq!(r["defaultTab"].as_f64(), Some(36.0)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ruler(&mut h)["tabs"][0]["pos"].as_f64(), Some(36.0), "one undo step"); +} diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/thesaurus_tests.rs new file mode 100644 index 00000000..985e6401 --- /dev/null +++ b/crates/ui-egui/src/thesaurus_tests.rs @@ -0,0 +1,54 @@ +//! Review › Thesaurus driven like a person: caret in a word, open the pane, click a synonym. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn thesaurus_pane_swaps_the_word_at_the_caret() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Review".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Thesaurus").click(); + h.run_steps(3); + assert_eq!(h.state().ui.pane.as_deref(), Some("thesaurus")); + assert_eq!(h.state().thesaurus_query, "Goal"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/thesaurus.png"); + } + h.get_by_label("Objective").click(); + h.run_steps(3); + let text = h + .state() + .session + .active() + .unwrap() + .shapes() + .iter() + .find(|x| x.id.0 as u64 == id) + .and_then(|x| x.text.as_ref()) + .map(|t| t.paragraphs[0].text()); + assert_eq!(text.as_deref(), Some("Our Objective is growth.")); + assert_eq!(h.state().thesaurus_query, "Objective", "the pane follows the new word"); + + // Typing a word that isn't listed says so instead of showing nothing. + h.state_mut().thesaurus_query = "zzxq".into(); + h.run_steps(2); + assert!(h.query_by_label_contains("No synonyms").is_some()); +} diff --git a/docs/architecture.md b/docs/architecture.md index acd3521d..cbe0835e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,8 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; 28 dialogs, 226 commands; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry 227; dialogs stay 27; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry counted at 236; dialogs stay 27; the test-count line stays 504; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog, 227 commands, 28 dialogs; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph and Tabs dialogs, 229 commands, 29 dialogs; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -40,6 +42,10 @@ nothing below L6 uses egui, eframe, winit or rfd. | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (229 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 29 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with @@ -107,5 +113,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | 2026-10-14 | minor | Command registry is 227 (`design.effects`). Dialogs stay 27. The test-count line stays 504 | | 2026-10-14 | minor | Command registry counted at 236, including `master.preserve` and `master.placeholders`. Dialogs stay 27. The test-count line stays 504. The menu measurement and the d556ff9 snapshot still count 226 | | 2026-10-13 | trivial | Animation preview draws the current slide's timeline on the editor canvas | +| 2026-10-14 | minor | Paragraph and Tabs dialogs; command registry 229; 29 dialogs | +| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | diff --git a/docs/gaps.md b/docs/gaps.md index 1073cc7e..c9e1a5b2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -28,6 +28,10 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview plays on the editor canvas; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page in the editor and the show; pinch-zoom remains; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -308,6 +312,14 @@ selected effect, and most of the effect gallery. Detail in band round the face outline (not round the extruded silhouette); no command or pane to apply 3-D). - **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 1–2 h (fitting the other rigs and materials to PowerPoint renders), plus the Format Shape page. + `prstTxWarp` presets render, their adjusts round-trip, and a selected warp shows a yellow handle + per adjust that dragging sets; still no Transform gallery or command to apply one, and arc/circle + placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation" + (partial: bevels, camera presets and rotations and face lighting render as approximations and + `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and + no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 1–2 h (Transform gallery and command), 6–10 h (3-D extrusion, + rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -341,6 +353,9 @@ sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; need matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text (6–10 h), 3-D models and stock content (6–10 h; Insert › Icons places a Lucide stroke set). +show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept, +markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile +encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set). ## UI/UX @@ -356,6 +371,7 @@ Detail in [ui-parity.md](ui-parity.md). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action Settings, Print, Insert Object…). Size and Position is one of the 28. 8–14 h. @@ -375,6 +391,13 @@ Detail in [ui-parity.md](ui-parity.md). 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. +- **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline, + colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets + and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. +- **Dialogs**: 29 dialogs vs PowerPoint's ~60. The Font dialog and the Paragraph dialog are in + (Paragraph: alignment, first-line and hanging indents, spacing before/after, line spacing, and a + Tabs dialog, each OK one undo step). Still missing: Bullets and Numbering, Find/Replace, Replace + Fonts, Action Settings, Print, Insert Object…. 4–8 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -478,6 +501,10 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects | | 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open | | 2026-10-14 | minor | A trackpad swipe (and the mouse wheel, in the show) turns the page. Pinch-zoom remains | +| 2026-10-14 | minor | Paragraph dialog sets alignment, indentation, spacing and line spacing in one step; its Tabs… button opens a standalone Tabs dialog | +| 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | +| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing | +| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3ddfbad6..06ff6985 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,7 +3,7 @@ > **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) The summary between ` -**Weighted breadth parity: 80%** over 191 features. +**Weighted breadth parity: 82%** over 193 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| @@ -18,15 +18,15 @@ The summary between ` | P2 | 54 | 14 | 17 | 23 | 42% | | P1 | 65 | 38 | 21 | 6 | 75% | | P2 | 54 | 13 | 16 | 25 | 39% | +| P1 | 66 | 43 | 18 | 5 | 79% | +| P2 | 55 | 16 | 20 | 19 | 47% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | | Slides | 90% | -| Views | 93% | -| Masters & themes | 85% | -| Views | 90% | +| Views | 98% | | Masters & themes | 88% | | Views | 90% | | Masters & themes | 88% | @@ -34,14 +34,12 @@ The summary between ` | Shapes | 91% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 82% | | Tables | 95% | | Tables | 92% | | Charts | 65% | -| SmartArt | 33% | -| Pictures | 52% | | SmartArt | 50% | -| Pictures | 48% | +| Pictures | 52% | | Media | 67% | | Transitions | 100% | | Animations | 79% | @@ -50,6 +48,7 @@ The summary between ` | Animations | 85% | | Slide show | 66% | | Review | 60% | +| Review | 70% | | Draw | 33% | | Undo & clipboard | 100% | | Draw | 50% | @@ -107,7 +106,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Views | Notes Page | P1 | D | | Views | Reading View | P1 | D | | Views | Zoom, fit to window | P0 | D | -| Views | Rulers, gridlines, guides | P1 | P | +| Views | Rulers, gridlines, guides | P1 | D | | Views | Smart guides | P1 | D | | Views | Grayscale / black and white view | P2 | D | | Views | New window / arrange windows | P2 | P | @@ -169,7 +168,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Bullets and numbering | P0 | D | | Text | Picture bullets, custom bullet symbols | P2 | P | | Text | List levels (Tab / Shift+Tab) | P0 | D | -| Text | Tab stops | P2 | P | +| Text | Tab stops | P2 | D | | Text | Columns | P2 | D | | Text | Vertical anchor, margins, wrap | P0 | D | | Text | Autofit (shrink on overflow, resize shape) | P0 | D | @@ -178,6 +177,8 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Spelling check | P1 | P | | Text | Thesaurus | P2 | M | | Text | AutoCorrect and smart quotes | P2 | Y | +| Text | Thesaurus | P2 | P | +| Text | AutoCorrect and smart quotes | P2 | M | | Text | Symbols and emoji | P1 | D | | Text | Equations | P2 | M | | Text | Hyperlinks (URL, slide, email) | P1 | D | @@ -244,7 +245,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slide show | Multi-monitor presenting | P1 | M | | Review | Comments (add, reply, resolve, delete) | P1 | D | | Review | Accessibility checker | P2 | D | -| Review | Compare presentations | P2 | M | +| Review | Compare presentations | P2 | P | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | | Draw | Ink to shape / text | P2 | P | diff --git a/docs/roadmap.md b/docs/roadmap.md index ce8429ff..71e7996f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -17,6 +17,8 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (go to slide shows the typed number; previous: trivial, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (canvas animation preview; beta item 9 keeps the timeline; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options; previous: core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -117,9 +119,12 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | | M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp adjust handles | +| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp gallery and command | | M10 | Views | done | grid options, New Window | | M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | | M10 | Views | done | grid options, Arrange All | +| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | +| M10 | Views | done | New Window | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | | M13 | Automation and CLI parity | done | | @@ -168,5 +173,7 @@ plan above. | 2026-10-14 | trivial | Present workflow notes presenter timer pause/reset, clock and notes size. Display placement stays open | | 2026-10-14 | trivial | Present workflow notes See All Slides and zoom | | 2026-10-13 | trivial | Canvas animation preview landed; beta item 9 keeps the timeline and preview from the selected effect | +| 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open | +| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c676999..7e362023 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -18,6 +18,10 @@ > **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-13 · **Last updated:** 2026-10-13 · **Change:** trivial (Preview in editor re-scored done; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Views: Grid Options landed; New Window remains; previous: hours to ~95% per audience added; ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -201,11 +205,13 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) | | Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | | Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, Arrange All | [ui-parity.md](ui-parity.md) | +| Views | 4 | 90% | ~80% | 3–5 | New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp adjust handles, WordArt depth | [ui-parity.md](ui-parity.md) | | Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx` | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | +| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp gallery and command, WordArt depth | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | | Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) | | Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | @@ -286,6 +292,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Automation:** 228 commands, every one reachable from `deckcraft-cli`, the JSON control channel - **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel - **Automation:** 228 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 229 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. The menu-bar measurement and the d556ff9 snapshot still count 226. @@ -345,6 +353,10 @@ scored done (16 open issues). | 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-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% | +| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged | +| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged | +| 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages unchanged | +| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4b521083..77eefa4c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -23,6 +23,8 @@ > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview on the editor canvas; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Format Shape fill transparency follows the selection; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -32,6 +34,8 @@ > **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-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -93,6 +97,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | | Format 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 | | 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 | @@ -114,6 +119,10 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Design ▸ Effects | named theme effect schemes | DeckCraft, Soft, Crisp, Lift, Glow and Plain (`design.effects`); colours and fonts stay | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media (start, end and fades follow the selection), SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Font, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 29 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Grid Options, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Find and Replace, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -123,17 +132,21 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Smart guides (edges, centres, equal spacing) | yes | yes | -| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle | +| Snap to grid, grid options, drawing guides | yes | Grid Options (`view.gridOptions`): spacing, snap to grid, snap to other objects, display grid and guides; the on-screen grid uses that spacing | | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | | Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet | +| Edit points on freeforms | yes | **no** | +| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; 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 | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | +| Exact size/position, lock aspect | yes | yes | +| Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | @@ -144,8 +157,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). ## Keyboard shortcuts Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate, -group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from -start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag +group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, the Font +dialog (Cmd+T), show from start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag precision without snapping, F6 pane cycling, ribbon KeyTips (Windows). ### Text editing keys @@ -194,13 +207,18 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Colors is in) | 8–14 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Fonts is in) | 8–14 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Action Settings, Print, Insert Object) | 8–14 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 4–8 | +| Missing dialogs (Font, Bullets and Numbering, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome and system appearance (#41, #71) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Arrange All (New Window is one extra window) | 2–3 | +| Edit points UI, crop depth | 3–6 | +| Multiple windows | 3–5 | | A screenshot comparison pass on the most-used tabs | 2–4 | -| **Total** | **~30–52** | +| **Total** | **~29–51** | ## Revision history @@ -231,6 +249,10 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps | | 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas | | 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss | +| 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog | +| 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | +| 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | +| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |