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() }) }
}