Merge remote-tracking branch 'upstream/main' into fix-211-markdown-outlines

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
lloyd
2026-10-13 08:58:03 +02:00
114 changed files with 19058 additions and 732 deletions
+91 -37
View File
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
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)),
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
cmd!(
"arrange.reorder",
"Reorder Objects",
["Arrange"],
None,
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
has_slide,
reorder
),
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
let ids = targets(s, p)?;
// Already in place (front of its group, say): no change and no undo step.
let mut trial = s.doc()?.shapes().to_vec();
order_tree(&mut trial, &ids, how, 0);
if stacking(&trial) == stacking(s.doc()?.shapes()) {
return ok();
}
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
match how {
2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
list.extend(rest);
list.append(&mut moved);
}
-2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
moved.extend(rest);
*list = moved;
}
1 => {
for i in (0..list.len().saturating_sub(1)).rev() {
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
if a == Some(true) && b == Some(false) {
list.swap(i, i + 1);
}
}
}
_ => {
for i in 1..list.len() {
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
if a == Some(false) && b == Some(true) {
list.swap(i - 1, i);
}
}
}
}
order_tree(list, &ids, how, 0);
Ok(())
})?;
ok()
}
/// Every shape id with its depth, in tree order.
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
let mut out = vec![];
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
out
}
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
if depth > 64 {
return;
}
if list.iter().any(|x| ids.contains(&x.id)) {
order_list(list, ids, how);
}
for sh in list.iter_mut() {
if let Some(children) = sh.children_mut() {
order_tree(children, ids, how, depth + 1);
}
}
}
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
match how {
2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
list.extend(rest);
list.append(&mut moved);
}
-2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
moved.extend(rest);
*list = moved;
}
1 => {
for i in (0..list.len().saturating_sub(1)).rev() {
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
if a == Some(true) && b == Some(false) {
list.swap(i, i + 1);
}
}
}
_ => {
for i in 1..list.len() {
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
if a == Some(false) && b == Some(true) {
list.swap(i - 1, i);
}
}
}
}
}
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
/// A shape stays in the list that holds it, so a group's child stays in its group.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
let st = s.doc()?;
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
None => return Err(bad("arrange.reorder", "no such shape")),
Some(None) => st.shapes(),
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
};
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
if from == to.min(siblings.len().saturating_sub(1)) {
return Ok(json!({"index": from}));
}
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
let sh = list.remove(i);
list.insert(to.min(list.len()), sh);
Ok(())
})?;
ok()
let at = to.min(list.len());
list.insert(at, sh);
Ok(json!({"index": at}))
})
}
/// Bounds of shapes (rotation included).
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
} else {
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
};
edit_shapes(s, p, "arrange.align", |sh| {
edit_geometry(s, p, "arrange.align", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let b = x.bounds();
let (dx, dy) = match edge.as_str() {
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
targets_pos.push((*id, d));
cur += (if horiz { b.width() } else { b.height() }) + gap;
}
edit_shapes(s, p, "arrange.distribute", |sh| {
edit_geometry(s, p, "arrange.distribute", |sh| {
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
if horiz {
x.x += d;
+1
View File
@@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
background: None,
show_master_shapes: true,
preserve: true,
color_map: None,
raw_ext: None,
},
);
+79 -12
View File
@@ -15,7 +15,16 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(
query "file.info",
"Info",
["File"],
None,
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
has_doc,
info
),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
cmd!(
@@ -83,7 +92,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
s.add_document(DocState::new(p, path, title))
let missing_fonts = crate::fonts::missing_fonts(&p);
let mut st = DocState::new(p, path, title);
st.missing_fonts = missing_fonts;
s.add_document(st)
}
#[cfg(not(target_arch = "wasm32"))]
@@ -93,7 +105,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let doc = open_presentation(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
let st = s.doc()?;
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
}
#[cfg(target_arch = "wasm32")]
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
@@ -105,7 +118,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
let doc = open_presentation(name, &data)?;
let i = add_opened(s, name, None, doc);
Ok(json!({"document": i}))
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
}
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
@@ -118,26 +131,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
}
}
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
/// includeHidden, textLayer, frame}`.
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
o.slides = match p.get("slides") {
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
_ => None,
};
o.include_hidden = bool_or(p, "includeHidden", false);
o.text_layer = bool_or(p, "textLayer", true);
o.frame_slides = bool_or(p, "frame", true);
o
Ok(o)
}
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
None => (part, part),
};
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
let (lo, hi) = (a.min(b), a.max(b));
if lo == 0 || hi > count {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(invalid());
}
Ok(out)
}
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
}
pub fn format_for_path(path: &str) -> &'static str {
@@ -264,7 +310,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": written}))
}
"pdf" => {
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
let bytes = pdf_bytes(&st.doc, p)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@@ -276,6 +322,27 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// File › Info: what the Info page shows about the active presentation.
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let doc = &st.doc;
#[cfg(not(target_arch = "wasm32"))]
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
#[cfg(target_arch = "wasm32")]
let bytes: Option<u64> = None;
Ok(json!({
"title": st.title(),
"path": st.path,
"format": st.path.as_deref().map(format_for_path),
"dirty": st.is_dirty(),
"slides": doc.slides.len(),
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
"sections": doc.sections.len(),
"bytes": bytes,
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
}))
}
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
if let Some(i) = s.active_index() {
s.close_document(i);
+37 -7
View File
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
color
),
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
cmd!(
"format.case",
"Change Case",
["Home", "Font"],
Some("Shift+F3"),
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
has_text_or_shapes,
case
),
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
cmd!(
"format.spacing",
@@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
}
fn case(s: &mut Session, p: &Value) -> Result<Value> {
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = t.ordered();
if a == b {
return ok();
let (mut a, mut b) = t.ordered();
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
let collapsed = a == b;
if collapsed {
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
let (wa, wb) = ed::word_at(&body, a);
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
return ok();
}
(a, b) = (wa, wb);
}
let m = mode.clone();
super::text::edit_text(s, move |body, _| {
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
super::text::edit_text(s, move |body, t| {
for pi in a.0..=b.0 {
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
let n = para.char_len();
@@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
pos += len;
}
}
if collapsed {
t.anchor = a;
t.caret = a;
}
})?;
return ok();
}
let mode = if mode == "cycle" {
let all: String =
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
"
",
);
ed::next_case(&all).to_string()
} else {
mode
};
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
+14
View File
@@ -11,6 +11,7 @@ pub mod inspect;
pub mod media;
pub mod merge;
pub mod review;
pub mod selection;
pub mod shape;
pub mod slide;
pub mod table;
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(insert::specs());
v.extend(shape::specs());
v.extend(arrange::specs());
v.extend(selection::specs());
v.extend(merge::specs());
v.extend(text::specs());
v.extend(format::specs());
@@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
})
}
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
/// locked targets are left out, and when every target is locked nothing changes.
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
let ids = targets(s, p)?;
let st = s.doc()?;
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
if !ids.is_empty() && free.is_empty() {
return Err(bad(cmd, "the object is locked"));
}
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
}
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
match sel.target {
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
+126
View File
@@ -0,0 +1,126 @@
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
use deckcraft_model::{Shape, ShapeId};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"selection.select",
"Select",
["Selection Pane"],
None,
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
has_slide,
select
),
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
]
}
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
/// object from the list shows it again.
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
let st = s.doc()?;
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
if valid.is_empty() {
return Err(bad("selection.select", "no such shape"));
}
let kind = valid.first().and_then(in_group);
valid.retain(|i| in_group(i) == kind);
let keep: Vec<ShapeId> = if toggle || add {
let current = st.selection.shapes.clone();
if current.first().is_some_and(|c| in_group(c) != kind) {
// Can't mix objects in groups with top-level ones: nothing changes.
return Ok(json!({"selected": current}));
}
current
} else {
vec![]
};
let mut next = keep;
for i in &valid {
match next.iter().position(|x| x == i) {
Some(k) if toggle => {
next.remove(k);
}
Some(_) => {}
None => next.push(*i),
}
}
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
if reveal.is_empty() {
s.select(|_, sel| {
sel.text = None;
sel.cells = None;
sel.shapes = next.clone();
})?;
} else {
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
for id in &reveal {
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
sh.hidden = false;
}
}
sel.text = None;
sel.cells = None;
sel.shapes = next.clone();
Ok(())
})?;
}
Ok(json!({"selected": s.doc()?.selection.shapes}))
}
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
fn any_hidden(v: &[Shape]) -> bool {
let mut found = false;
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
found
}
if !any_hidden(s.doc()?.shapes()) {
return ok();
}
s.edit(|doc, sel| {
fn rec(v: &mut [Shape], depth: usize) {
for sh in v {
sh.hidden = false;
if depth < 64
&& let Some(c) = sh.children_mut()
{
rec(c, depth + 1);
}
}
}
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
Ok(())
})?;
ok()
}
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
if s.doc()?.shapes().iter().all(|x| x.hidden) {
return ok();
}
s.edit(|doc, sel| {
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
sh.hidden = true;
}
Ok(())
})?;
ok()
}
#[cfg(test)]
#[path = "selection_tests.rs"]
mod tests;
+264
View File
@@ -0,0 +1,264 @@
//! Selection pane: reorder, groups, hide/show, lock and undo.
use serde_json::{Value, json};
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::*;
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
fn deck() -> (Session, Vec<u32>) {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let mut ids = vec![];
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
let x = 20.0 + 110.0 * i as f64;
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
let id = r["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
ids.push(id);
}
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
ids.extend([h, g]);
(s, ids)
}
/// The tree as text, back to front: `A B C G[D H[E F]]`.
fn tree(s: &Session) -> String {
fn rec(v: &[deckcraft_model::Shape]) -> String {
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
}
rec(s.doc().unwrap().shapes())
}
/// (hidden, locked)
fn flags(s: &Session, id: u32) -> (bool, bool) {
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
(sh.hidden, sh.locked)
}
fn selected(s: &Session) -> Vec<u32> {
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
}
fn undo(s: &mut Session) {
s.execute("edit.undo", &json!({})).unwrap();
}
fn bx(s: &mut Session, id: u32) -> Value {
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
}
#[test]
fn bring_forward_and_send_backward_stay_inside_the_group() {
let (mut s, ids) = deck();
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
assert_eq!(tree(&s), "A B C G[D H[E F]]");
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
assert_eq!(tree(&s), "B A C G[D H[E F]]");
// A child moves among its siblings only and stops at the group's front.
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[E F] D]");
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[E F] D]");
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[F E] D]");
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
assert_eq!(tree(&s), "B A C G[D H[F E]]");
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
// Steps that changed nothing left no undo step.
undo(&mut s);
assert_eq!(tree(&s), "B A C G[H[F E] D]");
undo(&mut s);
undo(&mut s);
assert_eq!(tree(&s), "B A C G[D H[E F]]");
}
#[test]
fn reorder_is_the_drag_and_drop_among_siblings() {
let (mut s, ids) = deck();
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
// Drag A to the front of the slide (an index past the end clamps).
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
assert_eq!(tree(&s), "B C G[D H[E F]] A");
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
assert_eq!(tree(&s), "G[D H[E F]] B C A");
// A child is dragged within its group: it never leaves it.
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
assert_eq!(tree(&s), "G[H[E F] D] B C A");
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
assert_eq!(tree(&s), "G[H[F E] D] B C A");
// Dropping where it already is changes nothing (no undo step).
let n = s.doc().unwrap().history.undo.len();
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n);
// One undo step per drop.
undo(&mut s);
assert_eq!(tree(&s), "G[H[E F] D] B C A");
undo(&mut s);
undo(&mut s);
undo(&mut s);
assert_eq!(tree(&s), "A B C G[D H[E F]]");
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
}
#[test]
fn eye_show_all_and_hide_all() {
let (mut s, ids) = deck();
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
assert!(flags(&s, b).0);
// Hiding a group hides only the group; its children keep their own flags.
s.execute("shape.visible", &json!({"id": g})).unwrap();
assert!(flags(&s, g).0 && !flags(&s, d).0);
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
// Hide All hides the top level only.
s.execute("selection.hideAll", &json!({})).unwrap();
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
// Show All shows everything, children included.
s.execute("selection.showAll", &json!({})).unwrap();
for id in &ids {
assert!(!flags(&s, *id).0, "{id} still hidden");
}
let n = s.doc().unwrap().history.undo.len();
s.execute("selection.showAll", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
undo(&mut s);
assert!(flags(&s, a).0 && flags(&s, e).0);
undo(&mut s);
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
}
#[test]
fn lock_cascades_through_groups_and_undoes() {
let (mut s, ids) = deck();
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
s.execute("shape.lock", &json!({"id": a})).unwrap();
assert!(flags(&s, a).1);
s.execute("shape.lock", &json!({"id": a})).unwrap();
assert!(!flags(&s, a).1, "toggles");
// Locking a group locks everything in it.
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
// A child can be unlocked on its own.
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
assert!(flags(&s, g).1 && !flags(&s, d).1);
// Unlocking the group unlocks everything in it.
s.execute("shape.lock", &json!({"id": g})).unwrap();
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
undo(&mut s);
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
undo(&mut s);
undo(&mut s);
assert!(!flags(&s, g).1 && !flags(&s, e).1);
}
#[test]
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
let (mut s, ids) = deck();
let (a, b) = (ids[0], ids[1]);
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
let before = bx(&mut s, a);
for (cmd, p) in [
("shape.move", json!({"ids": [a], "dx": 10})),
("shape.resize", json!({"ids": [a], "w": 300})),
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
("shape.rotate", json!({"ids": [a], "deg": 45})),
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
("arrange.align", json!({"ids": [a], "edge": "right"})),
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
}
assert_eq!(bx(&mut s, a), before);
// Mixed selections: the unlocked ones move, the locked one stays.
let bb = bx(&mut s, b);
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
assert_eq!(bx(&mut s, a), before);
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
// Formatting and the name still change.
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
// The arrow keys don't nudge it.
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
let _ = s.key("ArrowRight", Mods::default());
assert_eq!(bx(&mut s, a), before);
}
#[test]
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
let (mut s, ids) = deck();
let a = ids[0];
s.prefs.snap_to_grid = false;
s.prefs.smart_guides = false;
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
// A: x 20..120, y 50..130.
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
assert_eq!(bx(&mut s, a)["x"], 20.0);
// No resize handle: pressing on the SE corner and dragging doesn't size it.
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
assert_eq!(bx(&mut s, a)["w"], 100.0);
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
}
#[test]
fn select_from_the_pane() {
let (mut s, ids) = deck();
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
s.execute("selection.select", &json!({"id": b})).unwrap();
assert_eq!(selected(&s), vec![b]);
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![b, c]);
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![c]);
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
assert_eq!(selected(&s), vec![c, a, b]);
// A child inside a group; children across nesting levels go together.
s.execute("selection.select", &json!({"id": d})).unwrap();
assert_eq!(selected(&s), vec![d]);
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![d, e]);
// ...but not with top-level objects: Ctrl+click on one is ignored.
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![d, e]);
s.execute("selection.select", &json!({"id": a})).unwrap();
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![a]);
// Picking makes no undo step...
let n = s.doc().unwrap().history.undo.len();
s.execute("selection.select", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n);
// ...unless it shows a hidden object again.
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
s.execute("selection.select", &json!({"id": c})).unwrap();
assert_eq!(selected(&s), vec![c]);
assert!(!flags(&s, c).0, "picking a hidden object shows it");
undo(&mut s);
assert!(flags(&s, c).0);
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
}
#[test]
fn rename_from_the_pane_undoes() {
let (mut s, ids) = deck();
let e = ids[4];
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
undo(&mut s);
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
}
+34 -10
View File
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
cmd!(
"shape.lock",
"Lock",
["Selection Pane"],
None,
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
has_selection,
lock
),
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
cmd!(
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
edit_shapes(s, p, "shape.move", |sh| {
edit_geometry(s, p, "shape.move", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
let lock = bool_or(p, "lockAspect", false);
edit_shapes(s, p, "shape.resize", |sh| {
edit_geometry(s, p, "shape.resize", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, p, "shape.setBounds", |sh| {
edit_geometry(s, p, "shape.setBounds", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = f64_param(p, "x").unwrap_or(x.x);
x.y = f64_param(p, "y").unwrap_or(x.y);
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
edit_shapes(s, p, "shape.rotate", |sh| {
edit_geometry(s, p, "shape.rotate", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
x.rot = ((r % 360.0) + 360.0) % 360.0;
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_shapes(s, p, "shape.flip", |sh| {
edit_geometry(s, p, "shape.flip", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_shapes(s, p, "shape.adjust", |sh| {
edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
while adj.len() <= i {
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
fn change(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
edit_shapes(s, p, "shape.change", |sh| {
edit_geometry(s, p, "shape.change", |sh| {
sh.geom = Geom::preset(info.name);
Ok(())
})
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "locked");
edit_shapes(s, p, "shape.lock", |sh| {
sh.locked = v.unwrap_or(!sh.locked);
let to = v.unwrap_or(!sh.locked);
set_locked(sh, to, 0);
Ok(())
})
}
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
sh.locked = to;
if depth > 64 {
return;
}
if let Some(children) = sh.children_mut() {
for c in children {
set_locked(c, to, depth + 1);
}
}
}
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
edit_shapes(s, p, "picture.crop", |sh| {
edit_geometry(s, p, "picture.crop", |sh| {
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
for (i, v) in c.iter().enumerate() {
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
+213 -15
View File
@@ -17,7 +17,38 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
// The input method's composition: shown in place, an undo step only once committed.
CommandSpec {
id: "text.compose",
label: "Compose Text",
menu: &[],
shortcut: None,
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
enabled: has_text,
run: compose,
journal: false,
undoable: false,
},
cmd!(
noundo "text.commit",
"Commit Composition",
[],
None,
"{text} — the input method's result replaces its composition, as one undo step",
has_text,
commit
),
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
cmd!(
"text.delete",
"Delete Text",
[],
Some("Backspace"),
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
has_text,
delete
),
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
@@ -144,6 +175,35 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
))
}
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
/// table cells, which aren't laid out on the slide here.
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
if t.notes || t.cell.is_some() {
return None;
}
let sh = st.shape(t.shape)?;
let ctx = ctx_for(&st.doc, &st.selection)?;
let x = xfrm_of(&st.doc, &st.selection, sh);
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let mut body = sh.text.clone().unwrap_or_default();
if body.paragraphs.is_empty() {
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
}
let l = deckcraft_text::layout(
&ctx,
sh,
&body,
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
);
// After End on a wrapped line the caret is drawn at that line's end.
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
}
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
@@ -184,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
}
s.select(|_, sel| {
sel.shapes = vec![id];
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
})?;
Ok(json!({"id": id, "at": [at.0, at.1]}))
}
@@ -340,6 +400,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
ok()
}
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
fn ime_text(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
/// Is the composition still where it was put (the paragraph holds all of it)?
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
let Some(t) = &st.selection.text else { return false };
let Some(body) = body_of(st, t) else { return false };
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
}
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
/// goes back to how it was before the composition began.
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
let n = text.chars().count();
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
(x < y).then_some((x.min(n), y.min(n)))
});
let current = s.doc()?.composition.clone();
// A composition whose text was changed under it is kept as it is; a new one starts.
let current = match current {
Some(c) if !composition_fits(s.doc()?, &c) => {
s.end_composition();
None
}
c => c,
};
match current {
None if text.is_empty() => Ok(json!({"composing": false})),
None => {
let st = s.doc()?;
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
let (start, props) = edit_text(s, |body, t| {
let (a, b) = t.ordered();
let at = ed::delete(body, a, b);
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
let c = ed::insert(body, at, &text, props.clone());
t.anchor = c;
t.caret = c;
(at, props)
})?;
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
}
Some(c) if text.is_empty() => {
let st = s.doc_mut()?;
st.doc = c.before;
st.selection = c.before_selection;
st.composition = None;
st.revision += 1;
Ok(json!({"composing": false}))
}
Some(c) => {
let (start, len, props) = (c.start, c.len, c.props.clone());
edit_text(s, |body, t| {
ed::delete(body, start, (start.0, start.1 + len));
let e = ed::insert(body, start, &text, props);
t.anchor = e;
t.caret = e;
})?;
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
}
}
}
/// `text.commit`: the input method's result replaces the composition (or is typed at the
/// insertion point when there is none) and the whole composition becomes one undo step.
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
if s.doc()?.composition.is_some() || !text.is_empty() {
compose(s, &json!({"text": text}))?;
}
s.end_composition();
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
Ok(json!({"caret": [caret.0, caret.1]}))
}
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
/// thick)` per line, thick under the part being converted. Empty without a composition or when
/// the edited text isn't laid out on the slide (notes, table cells).
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
let Some(sh) = st.shape(t.shape) else { return vec![] };
let Some(l) = layout_for(st, t) else { return vec![] };
let x = xfrm_of(&st.doc, &st.selection, sh);
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
let mut out = vec![];
let mut add = |a: usize, b: usize, thick: bool| {
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
}
};
add(0, c.len, false);
if let Some((a, b)) = c.active {
add(a, b.min(c.len), true);
}
out
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
insert_text(s, &text, None)
@@ -349,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
let dir = dir.to_string();
edit_text(s, move |body, t| {
let (a, b) = t.ordered();
let at = if a != b {
let at = if dir == "wordBackward" || dir == "wordForward" {
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
// (PowerPoint for Windows).
ed::delete_word(body, a, dir == "wordForward")
} else if a != b {
ed::delete(body, a, b)
} else {
let other = match dir.as_str() {
"forward" => ed::char_move(body, a, true),
"wordBackward" => ed::word_move(body, a, false),
"wordForward" => ed::word_move(body, a, true),
_ => {
// Backspace at the start of an indented/bulleted paragraph outdents first.
if a.1 == 0
@@ -382,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
}
/// Is paragraph `para` right-to-left (its lines run from the right)?
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
Some(line) => line.rtl,
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
}
}
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_text::Affinity;
let to = str_param(p, "to").unwrap_or("right").to_string();
let extend = bool_or(p, "extend", false);
let st = s.doc()?;
@@ -391,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let layout = layout_for(st, &t);
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
let caret = t.caret;
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
let to = match (to.as_str(), rtl) {
("left", true) => "right",
("right", true) => "left",
("wordLeft", true) => "wordRight",
("wordRight", true) => "wordLeft",
(x, _) => x,
}
.to_string();
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
let mut upstream = false;
let new = match to.as_str() {
"left" if t.is_range() && !extend => collapse_to(true),
"right" if t.is_range() && !extend => collapse_to(false),
"left" => ed::char_move(&body, caret, false),
"right" => ed::char_move(&body, caret, true),
"wordLeft" => ed::word_move(&body, caret, false),
"wordRight" => ed::word_move(&body, caret, true),
// Ctrl+arrows with a selection go on from its start (left) or end (right).
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
"paraStart" => (caret.0, 0),
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
"paraUp" => ed::para_up(&body, caret),
"paraDown" => ed::para_down(&body, caret),
"start" => (0, 0),
"end" => ed::end(&body),
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
Some(l) => {
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
let r = match to.as_str() {
"up" => l.vertical(lp(caret), false, x),
"down" => l.vertical(lp(caret), true, x),
"lineStart" => l.line_bounds(lp(caret)).0,
_ => l.line_bounds(lp(caret)).1,
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
let (r, a) = match to.as_str() {
"up" => l.vertical_affine(lp(caret), aff, false, x),
"down" => l.vertical_affine(lp(caret), aff, true, x),
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
// End stays on its line: after a soft wrap the caret sits at the line's end.
_ => l.line_end_caret(lp(caret), aff),
};
upstream = a == Affinity::Upstream;
(r.para, r.ch)
}
None => match to.as_str() {
@@ -427,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
s.select(|_, sel| {
if let Some(tt) = sel.text.as_mut() {
tt.caret = new;
tt.line_end = if upstream { Some(new) } else { None };
if !extend {
tt.anchor = new;
}
}
})?;
Ok(json!({"caret": [new.0, new.1]}))
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -479,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
let at = pos_param(p, "at").unwrap_or(t.caret);
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
let body = body_of(st, &t);
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
s.select(|_, sel| {
if let Some(t) = sel.text.as_mut() {
t.anchor = (at.0, 0);
t.caret = (at.0, n);
t.caret = end;
}
})?;
ok()