mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -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() }) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user