mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 00:41:28 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(¶_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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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() }) }
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user