Merge the format, canvas and dialog PRs integrated on land-r4 (#567 #569 #630 #637 #536 #559 #519 #506 #549 #556 #525)

# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/panes.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:06:35 -04:00
41 changed files with 4786 additions and 82 deletions
+85
View File
@@ -9,6 +9,18 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<f64> {
let size = s.doc()?.doc.slide_size;
Ok(if vertical { size.width } else { size.height })
}
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
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<usize> {
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<Value> {
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<Value> {
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<Value> {
Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default())
}
#[cfg(test)]
#[path = "guides_tests.rs"]
mod guides_tests;
+1 -1
View File
@@ -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<MediaId>) {
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
deckcraft_model::walk(shapes, &mut |s, _| {
match &s.kind {
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
+240
View File
@@ -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<CommandSpec> {
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<u64>,
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<char>,
case: bool,
whole: bool,
}
impl Opts {
fn from(p: &Value, cmd: &str) -> Result<Self> {
let pat: Vec<char> = 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<char> {
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(&para_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<char> = 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<Value> {
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<Value> {
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<char> = "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::<String>(), "Hola world");
assert_eq!(p.runs[0].text, "Hola");
assert_eq!(p.runs[0].props, bold);
}
}
+765 -1
View File
@@ -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<CommandSpec> {
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|<fraction>, 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<CommandSpec> {
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<Value> {
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<String>,
size: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<String>,
color: Option<deckcraft_model::ColorRef>,
strike: Option<Strike>,
baseline: Option<f64>,
caps: Option<Caps>,
spacing: Option<f64>,
}
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<Option<bool>> {
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<Option<&'a str>> {
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<FontEdits> {
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<Value> {
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<Value> {
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<Value> {
})
}
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<Option<f64>> {
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<Align> {
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<Spacing>) -> (&'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<Value> = 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<Value> {
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<Value> = 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<Value> {
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<Value> {
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<Value> {
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<Value> = 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<Value> {
const CMD: &str = "format.tabs";
let fin = |v: Option<f64>| 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<TabStop> = 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);
}
}
+76
View File
@@ -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());
}
+2
View File
@@ -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());
+124
View File
@@ -46,6 +46,20 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
cmd!(
"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<Value> {
ok()
}
/// The selected text when it lies in one paragraph, else the word at the caret.
fn word_under_caret(s: &Session) -> Option<String> {
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<Value> {
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<Value> {
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<u8>)> {
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<Value> {
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<Value> {
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<Value> {
revisions_json(s.doc()?)
}
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
s.doc_mut()?.compare = None;
ok()
}
#[cfg(test)]
mod tests {
use super::*;
+64
View File
@@ -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));
}
+61 -4
View File
@@ -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<CommandSpec> {
"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<Value> {
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<Value> {
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<Value> {
})
}
/// 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<LineEnd> {
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
}
+449
View File
@@ -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<Presentation>,
/// 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<SlideId> = 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<usize>,
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
pub details: Vec<String>,
}
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<SlideId, usize> = 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<Change> {
let pairs = pairs(cur, rev, by_position);
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = 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<String>) {
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<String> {
let mut out = vec![];
let mut used: HashSet<usize> = 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::<Vec<_>>();
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<SlideId>) -> 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<MediaId, MediaId> = 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<u32> = 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<ShapeId, ShapeId> = HashMap::new();
fn rec(
doc: &mut Presentation,
shapes: &mut [Shape],
taken: &HashSet<u32>,
ids: &mut HashMap<ShapeId, ShapeId>,
media: &HashMap<MediaId, MediaId>,
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<usize> {
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<String> {
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());
}
}
+5
View File
@@ -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<String>,
/// An input method's composition in progress ([`Composition`]).
pub composition: Option<Composition>,
/// Review › Compare in progress: the revised copy.
pub compare: Option<compare::Compare>,
}
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 {
+106
View File
@@ -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::<Vec<_>>().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);
}
+119
View File
@@ -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<String>,
}
fn senses_of(head: &str) -> Vec<Sense> {
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<Sense> {
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());
}
}
+153 -2
View File
@@ -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<f64>,
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<usize>,
}
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<deckcraft_geom::warp::WarpHandle> {
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<Value> {
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<usize> {
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<Guide>) {
@@ -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;