Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 e8a70d0d23 Fonts: Office's downloaded fonts are used; opening a file says which fonts are missing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 20:34:43 -04:00
43 changed files with 191 additions and 3599 deletions
-5
View File
@@ -128,11 +128,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
+37 -91
View File
@@ -13,15 +13,7 @@ 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 among its siblings (0 = back; a group's child stays in its group)} → {index}",
has_slide,
reorder
),
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", 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),
@@ -58,98 +50,52 @@ 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"))?;
order_tree(list, &ids, how, 0);
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);
}
}
}
}
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 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 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 sh = list.remove(i);
let at = to.min(list.len());
list.insert(at, sh);
Ok(json!({"index": at}))
})
list.insert(to.min(list.len()), sh);
Ok(())
})?;
ok()
}
/// Bounds of shapes (rotation included).
@@ -275,7 +221,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_geometry(s, p, "arrange.align", |sh| {
edit_shapes(s, p, "arrange.align", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let b = x.bounds();
let (dx, dy) = match edge.as_str() {
@@ -327,7 +273,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_geometry(s, p, "arrange.distribute", |sh| {
edit_shapes(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;
-14
View File
@@ -11,7 +11,6 @@ pub mod inspect;
pub mod media;
pub mod merge;
pub mod review;
pub mod selection;
pub mod shape;
pub mod slide;
pub mod table;
@@ -131,7 +130,6 @@ 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());
@@ -311,18 +309,6 @@ 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
@@ -1,126 +0,0 @@
//! 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
@@ -1,264 +0,0 @@
//! 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");
}
+10 -34
View File
@@ -76,15 +76,7 @@ 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",
["Selection Pane"],
None,
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
has_selection,
lock
),
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", 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!(
@@ -121,7 +113,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_geometry(s, p, "shape.move", |sh| {
edit_shapes(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);
@@ -133,7 +125,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_geometry(s, p, "shape.resize", |sh| {
edit_shapes(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);
@@ -162,7 +154,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
edit_geometry(s, p, "shape.setBounds", |sh| {
edit_shapes(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);
@@ -175,7 +167,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_geometry(s, p, "shape.rotate", |sh| {
edit_shapes(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;
@@ -186,7 +178,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_geometry(s, p, "shape.flip", |sh| {
edit_shapes(s, p, "shape.flip", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
@@ -201,7 +193,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_geometry(s, p, "shape.adjust", |sh| {
edit_shapes(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 {
@@ -219,7 +211,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_geometry(s, p, "shape.change", |sh| {
edit_shapes(s, p, "shape.change", |sh| {
sh.geom = Geom::preset(info.name);
Ok(())
})
@@ -578,30 +570,14 @@ 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| {
let to = v.unwrap_or(!sh.locked);
set_locked(sh, to, 0);
sh.locked = v.unwrap_or(!sh.locked);
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() };
@@ -619,7 +595,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_geometry(s, p, "picture.crop", |sh| {
edit_shapes(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)) {
-154
View File
@@ -17,27 +17,6 @@ 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!(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),
@@ -165,33 +144,6 @@ 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 },
);
let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?;
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))
@@ -388,112 +340,6 @@ 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)
-40
View File
@@ -25,7 +25,6 @@ impl Session {
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
/// Returns `{handled: bool, …}`.
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
self.end_composition();
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
let k = key.to_ascii_lowercase();
@@ -152,45 +151,6 @@ impl Session {
self.execute("text.insert", &json!({"text": text}))
}
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
/// `active` is the part being converted, in characters of `text`.
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
if !self.start_typing(text)? {
return Ok(json!({"handled": false}));
}
let mut p = json!({"text": text});
if let Some((a, b)) = active {
p["active"] = json!([a, b]);
}
self.execute("text.compose", &p)
}
/// An input method's result (#101): replaces the composition as one undo step.
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
if !self.start_typing(text)? {
return Ok(json!({"handled": false}));
}
self.execute("text.commit", &json!({"text": text}))
}
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
/// starts with everything selected. False when there is nowhere to type (or nothing to type
/// and nothing being edited).
fn start_typing(&mut self, text: &str) -> Result<bool> {
let st = self.doc()?;
if st.selection.text.is_some() {
return Ok(true);
}
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
if st.selection.shapes.len() != 1 || text.is_empty() {
return Ok(false);
}
self.execute("text.edit", &json!({"id": id}))?;
self.execute("text.selectAll", &json!({}))?;
Ok(true)
}
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
let st = self.doc()?;
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
-35
View File
@@ -131,24 +131,6 @@ pub struct Interaction {
pub selection: Selection,
}
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
/// before it began makes the whole composition one undo step.
#[derive(Clone, Debug)]
pub struct Composition {
/// Where the composed text starts: (paragraph, char).
pub start: (usize, usize),
/// Its length in characters (it never spans paragraphs).
pub len: usize,
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
pub active: Option<(usize, usize)>,
/// The formatting the composed text is typed with (as `text.insert` would choose).
pub props: Option<deckcraft_model::text::RunProps>,
/// The presentation and selection before the composition began.
pub before: Arc<Presentation>,
pub before_selection: Selection,
}
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Presentation>,
@@ -163,8 +145,6 @@ pub struct DocState {
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
/// documents); the app mentions them once.
pub missing_fonts: Vec<String>,
/// An input method's composition in progress ([`Composition`]).
pub composition: Option<Composition>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -183,7 +163,6 @@ impl DocState {
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
title,
missing_fonts: Vec::new(),
composition: None,
}
}
pub fn is_dirty(&self) -> bool {
@@ -424,10 +403,6 @@ impl Session {
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
// Any other edit (or a click, a key, undo) settles an input method's composition first.
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
self.end_composition();
}
if let Err(e) = (spec.enabled)(self) {
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
if !named {
@@ -478,16 +453,6 @@ impl Session {
Ok(r)
}
/// Keep an input method's composition as it stands (#101): its text stays and the whole
/// composition becomes one undo step. Nothing happens without one.
pub fn end_composition(&mut self) {
let Some(st) = self.active_mut() else { return };
let Some(c) = st.composition.take() else { return };
if !Arc::ptr_eq(&c.before, &st.doc) {
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
}
}
/// Change only the selection (no undo step).
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
let st = self.doc_mut()?;
-207
View File
@@ -567,210 +567,3 @@ fn inserted_hyperlinks_look_like_links() {
s.execute("format.underline", &json!({})).unwrap();
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
}
/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed
/// like any other; one can be dragged back onto the slide or out of it.
#[test]
fn shapes_off_the_slide_can_be_selected_and_moved() {
let mut s = session();
s.prefs.snap_to_grid = false;
s.prefs.smart_guides = false;
let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
// Inserting off the slide keeps the box where it was asked for.
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
s.execute("edit.select", &json!({"ids": []})).unwrap();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
// Hover and click wholly off the slide.
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap();
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![off]);
// Drag it further off, then back onto the slide.
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap();
s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap();
let v = s.execute("shape.inspect", &json!({"id": off})).unwrap();
assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0)));
s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap();
s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap();
s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap();
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0);
// The off-slide half of a straddling shape is hit too.
s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap();
s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap();
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
// A marquee started on the pasteboard selects what it encloses there.
s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap();
s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap();
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
}
fn title_text(s: &Session) -> String {
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
/// committed text is one undo step, and nothing on the way is an undo step of its own.
#[test]
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "A "})).unwrap();
let undo0 = s.doc().unwrap().history.undo.len();
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
s.compose_text(pre, None).unwrap();
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
let c = s.doc().unwrap().composition.clone().unwrap();
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
}
s.commit_text("한글").unwrap();
assert_eq!(title_text(&s), "A 한글");
assert!(s.doc().unwrap().composition.is_none());
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "A ");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(title_text(&s), "A 한글");
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
// then a new preedit.
s.compose_text("ㄴ", None).unwrap();
s.compose_text("", None).unwrap();
s.commit_text("ㄴ").unwrap();
s.compose_text("ㅁ", None).unwrap();
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
s.commit_text("마").unwrap();
assert_eq!(title_text(&s), "A 한글ㄴ마");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "A 한글ㄴ");
}
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
/// the text and the selection it replaced are as they were, with no undo step.
#[test]
fn ime_cancel_restores_the_text_and_selection() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
let undo0 = s.doc().unwrap().history.undo.len();
let rev = s.doc().unwrap().revision;
s.compose_text("せかい", Some((0, 3))).unwrap();
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
s.compose_text("世界", Some((0, 9))).unwrap();
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
s.compose_text("", None).unwrap();
assert_eq!(title_text(&s), "Hello world");
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
assert!(s.doc().unwrap().revision > rev, "redrawn");
// A commit of nothing without a composition is nothing.
s.commit_text("").unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
// A commit without a preedit (some input methods send only the result) is one step.
s.commit_text("世界").unwrap();
assert_eq!(title_text(&s), "Hello 世界");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
}
/// #101: anything else settles the composition first: its text stays and is its own undo step.
#[test]
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
let undo0 = s.doc().unwrap().history.undo.len();
s.compose_text("안", None).unwrap();
s.key("Right", Mods::default()).unwrap();
assert!(s.doc().unwrap().composition.is_none());
assert_eq!(title_text(&s), "안");
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
s.compose_text("녕", None).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
assert_eq!(title_text(&s), "안녕");
s.execute("text.edit", &json!({"id": title})).unwrap();
s.compose_text("하", None).unwrap();
s.execute("text.get", &json!({"id": title})).unwrap();
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
s.execute("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(title_text(&s), "안녕하!");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "안녕하");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(title_text(&s), "안녕");
// Text changed under the composition (here behind its back): a new one starts, no panic.
s.compose_text("ㄱ", None).unwrap();
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
s.compose_text("가", None).unwrap();
assert!(title_text(&s).ends_with('가'));
// Control characters (an Enter sent as text) never split the paragraph.
s.commit_text("가\n\r\u{b}").unwrap();
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
}
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
/// does; with nothing selected there is nowhere to compose.
#[test]
fn ime_on_a_selected_shape_replaces_its_text() {
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.select(|_, sel| {
sel.text = None;
sel.shapes = vec![ShapeId(id as u32)];
})
.unwrap();
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
s.compose_text("新", None).unwrap();
s.commit_text("新しい").unwrap();
let v = s.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(v["text"], "新しい");
s.select(|_, sel| {
sel.text = None;
sel.shapes.clear();
})
.unwrap();
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
}
/// #101: the composition is underlined in place, under the composed characters, with a thicker
/// line under the part being converted.
#[test]
fn ime_composition_underlines_follow_the_text() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
s.compose_text("にほんご", Some((2, 4))).unwrap();
let u = cmd::text::composition_underlines(s.doc().unwrap());
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
for (a, b, _) in &u {
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
}
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
s.commit_text("日本語").unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
}
+4 -16
View File
@@ -219,10 +219,6 @@ impl Session {
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
for id in st.selection.shapes.iter().rev() {
let Some(sh) = st.shape(*id) else { continue };
// A locked object shows no live handles: it can't be sized, rotated or reshaped.
if sh.locked {
continue;
}
let x = xfrm_of(&st.doc, &st.selection, sh);
if sh.is_line() {
let a = x.affine();
@@ -251,9 +247,8 @@ impl Session {
}
}
// Shapes, topmost first.
// Locked objects are still picked (to format them or type in them); they just don't move.
for sh in st.shapes().iter().rev() {
if sh.hidden {
if sh.hidden || sh.locked {
continue;
}
if let Some(h) = hit_shape(st, sh, p, tol) {
@@ -270,10 +265,6 @@ impl Session {
if !(p.x.is_finite() && p.y.is_finite()) {
return Ok(Value::Null);
}
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
if !matches!(ev.kind, PointerKind::Move) {
self.end_composition();
}
match ev.kind {
PointerKind::Move => {
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
@@ -428,9 +419,7 @@ impl Session {
} else {
vec![id]
};
// Locked objects stay put when the selection is dragged.
let origs: Vec<(ShapeId, Xfrm)> =
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
// A click (no drag) on text puts the caret there; unselected text boxes and
// placeholders take the caret straight away like PowerPoint.
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
@@ -440,8 +429,7 @@ impl Session {
sel.text = None;
sel.cells = None;
})?;
let duplicate = mods.alt && origs.len() == ids.len();
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
// Format Painter applies on click.
if self.painter.is_some() && !toggle {
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
@@ -472,7 +460,7 @@ impl Session {
match drag {
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
let mut d = p - start;
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
if !moved && d.hypot() < tol.max(1.0) {
return Ok(Value::Null);
}
if !moved {
-40
View File
@@ -443,46 +443,6 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> {
rec(shapes, id, 0)
}
/// The list holding `id` among its siblings: the tree's top level, or a group's children.
pub fn sibling_list_mut(shapes: &mut Vec<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
if depth > 64 {
return None;
}
if shapes.iter().any(|s| s.id == id) {
return Some(shapes);
}
for s in shapes.iter_mut() {
if let ShapeKind::Group { children, .. } = &mut s.kind
&& let Some(f) = rec(children, id, depth + 1)
{
return Some(f);
}
}
None
}
rec(shapes, id, 0)
}
/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent.
pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option<Option<ShapeId>> {
fn rec(shapes: &[Shape], parent: Option<ShapeId>, id: ShapeId, depth: usize) -> Option<Option<ShapeId>> {
if depth > 64 {
return None;
}
for s in shapes {
if s.id == id {
return Some(parent);
}
if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) {
return Some(f);
}
}
None
}
rec(shapes, None, id, 0)
}
/// Background of a slide, layout or master.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
+6 -21
View File
@@ -134,8 +134,11 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
/// Effective fill and the style colour (`phClr`) to resolve it with.
pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
let (lp, mp) = parents(ctx, shape);
let style_fill = |s: &Shape| {
s.style.as_ref().map(|st| {
for s in [Some(shape), lp, mp].into_iter().flatten() {
if let Some(f) = &s.fill {
return (Some(f.clone()), None);
}
if let Some(st) = &s.style {
let (idx, col) = &st.fill_ref;
let ph = ctx.color(col, None);
let f = match *idx {
@@ -143,25 +146,7 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(),
i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(),
};
(f, Some(ph))
})
};
for s in [Some(shape), lp, mp].into_iter().flatten() {
if let Some(f) = &s.fill {
// A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the
// gradient its style refers to, keeping its own direction, as PowerPoint draws it.
if let Fill::Gradient(g) = f
&& g.stops.is_empty()
&& let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s)
{
let mut g = g.clone();
g.stops = sg.stops;
return (Some(Fill::Gradient(g)), ph);
}
return (Some(f.clone()), None);
}
if let Some(r) = style_fill(s) {
return r;
return (f, Some(ph));
}
}
if shape.text_box {
-43
View File
@@ -62,29 +62,6 @@ fn shape_style_fill_resolves_through_theme() {
assert!(ph.is_some());
}
#[test]
fn a_gradient_without_stops_takes_them_from_its_style() {
// `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient
// the style's fillRef names, in the style's colour, along the shape's own direction.
let p = Presentation::default();
let s = &p.slides[0];
let ctx = Ctx::for_slide(&p, s).unwrap();
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
st.fill_ref.0 = 3;
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
let (f, ph) = resolve::fill(&ctx, &sh);
let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") };
let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") };
assert_eq!(g.stops, theme.stops);
assert_eq!(g.shape, own.shape, "the shape's own direction");
assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2)));
// A solid style fill has no stops to give: the gradient stays empty (draws nothing).
st.fill_ref.0 = 1;
let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own)));
}
#[test]
fn serde_roundtrip() {
let p = Presentation::default();
@@ -210,23 +187,3 @@ fn a_layout_colour_map_override_applies_to_its_slides() {
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
}
#[test]
fn sibling_lists_and_parents_reach_into_groups() {
let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() };
let group = |id: u32, children: Vec<Shape>| Shape {
id: ShapeId(id),
kind: ShapeKind::Group { children, child: Default::default() },
..Default::default()
};
let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)];
assert_eq!(parent_of(&tree, ShapeId(1)), Some(None));
assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2))));
assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4))));
assert_eq!(parent_of(&tree, ShapeId(9)), None);
let ids = |v: &Vec<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6]));
assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4]));
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
}
+3 -31
View File
@@ -104,18 +104,6 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
}
}
/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking
/// attributes; it reads an object as locked only when the whole set for its kind is there, so a
/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock.
fn is_locked(l: &El) -> bool {
let set: &[&str] = match l.local() {
"grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"],
"graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"],
_ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"],
};
set.iter().all(|a| l.bool(a) == Some(true))
}
/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…).
fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
let c = nv.and_then(|n| n.child("cNvPr"));
@@ -134,10 +122,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
match e.local() {
"cNvSpPr" => {
s.text_box = e.bool("txBox").unwrap_or(false);
s.locked = e.child("spLocks").is_some_and(is_locked);
s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
}
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked);
s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
}
"nvPr" => {
if let Some(ph) = e.child("ph") {
@@ -368,8 +356,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
let mut pics = vec![];
e.find_all("blip", &mut pics);
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
}
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
};
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
} else if uri.ends_with("/diagram") {
@@ -383,21 +370,6 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
s
}
/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the
/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them.
fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option<deckcraft_model::MediaId> {
for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) {
let Some(doc) = imp.pkg.xml(&rel.target) else { continue };
let mut shapes = vec![];
doc.root.find_all("shape", &mut shapes);
let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue };
let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue };
let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?;
return imp.media_for(&target);
}
None
}
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
let rel_ids = gd.child("relIds")?;
+9 -9
View File
@@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
Fill::Solid { color: c } => solid(w, c),
Fill::Gradient(g) => {
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
w.open0("a:gsLst");
let mut stops = g.stops.clone();
if stops.is_empty() {
stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) });
}
if stops.len() == 1 {
let mut s = stops[0].clone();
s.pos = 1.0;
stops.push(s);
}
if !stops.is_empty() {
w.open0("a:gsLst");
for s in &stops {
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
color(w, &s.color);
w.close("a:gs");
}
w.close("a:gsLst");
for s in &stops {
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
color(w, &s.color);
w.close("a:gs");
}
w.close("a:gsLst");
match &g.shape {
GradientShape::Linear { angle, scaled } => {
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
+2 -18
View File
@@ -177,24 +177,8 @@ fn nv_pr(w: &mut W, s: &Shape) {
}
}
/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and
/// reads back as locked): see `read::shapes::is_locked`.
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
let a = extra.t("noGrp", s.ph.is_some() || s.locked);
let a = match tag {
"a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked),
_ => {
let a = a
.t("noRot", s.locked)
.t("noMove", s.locked)
.t("noResize", s.locked)
.t("noEditPoints", s.locked)
.t("noAdjustHandles", s.locked)
.t("noChangeArrowheads", s.locked)
.t("noChangeShapeType", s.locked);
if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a }
}
};
let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked);
if a.v.is_empty() {
return;
}
@@ -474,7 +458,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
w.open0("p:nvGraphicFramePr");
cnvpr(w, x, o, id, s);
w.open0("p:cNvGraphicFramePr");
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked));
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked));
w.close("p:cNvGraphicFramePr");
nv_pr(w, s);
w.close("p:nvGraphicFramePr");
-23
View File
@@ -79,26 +79,3 @@ fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
format!("{}{}", &s[..i], &s[j..])
}
#[test]
fn a_gradient_without_stops_is_saved_without_stops() {
// `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving
// must not turn it into a gradient of its own (it used to become solid white).
use deckcraft_model::style::{Gradient, GradientShape};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Styled".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
fill: Some(Fill::Gradient(own.clone())),
style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
});
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
assert_eq!(s.fill, Some(Fill::Gradient(own)));
}
-211
View File
@@ -1,211 +0,0 @@
//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::PictureFill;
use deckcraft_model::table::Table;
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults};
fn png() -> Vec<u8> {
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]));
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
out.into_inner()
}
fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
}
/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a
/// locked group (one child unlocked, one hidden), and a locked picture, connector and table.
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let img = p.add_media("image1.png", "image/png", png());
let mut v = vec![];
let mut a = shape(&mut p, "Locked", 0.0);
a.locked = true;
v.push(a);
v.push(shape(&mut p, "Free", 100.0));
let mut t = shape(&mut p, "Hidden text", 200.0);
t.text_box = true;
t.hidden = true;
t.locked = true;
v.push(t);
let mut c1 = shape(&mut p, "Child locked", 300.0);
c1.locked = true;
let c2 = shape(&mut p, "Child free", 400.0);
let mut c3 = shape(&mut p, "Child hidden", 500.0);
c3.hidden = true;
let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0);
let mut g = Shape {
id: ShapeId(p.alloc_id()),
name: "Group".into(),
xfrm: Some(gx),
kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx },
locked: true,
..Default::default()
};
g.hidden = false;
v.push(g);
let mut pic = shape(&mut p, "Pic", 0.0);
pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } };
pic.locked = true;
v.push(pic);
let mut cx = shape(&mut p, "Conn", 100.0);
cx.kind = ShapeKind::Connector { start: None, end: None };
cx.geom = deckcraft_model::Geom::preset("straightConnector1");
cx.locked = true;
v.push(cx);
let mut tb = shape(&mut p, "Tbl", 200.0);
tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0));
tb.locked = true;
v.push(tb);
if let Some(s) = p.slides.first_mut() {
std::sync::Arc::make_mut(s).shapes = v;
}
p
}
fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape {
let mut found = None;
deckcraft_model::walk(&s.shapes, &mut |x, _| {
if found.is_none() && x.name == name {
found = Some(x);
}
});
found.unwrap_or_else(|| panic!("no shape {name}"))
}
fn entries(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
(0..z.len())
.map(|i| {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
(f.name().to_string(), d)
})
.collect()
}
fn rezip(es: &[(String, Vec<u8>)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (n, d) in es {
z.start_file(n.as_str(), o).expect("start");
z.write_all(d).expect("write");
}
z.finish().expect("finish").into_inner()
}
fn slide1_xml(bytes: &[u8]) -> String {
let es = entries(bytes);
let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
String::from_utf8(d.clone()).expect("utf8")
}
/// The element following `name="…"` up to the end of the object's non-visual properties.
fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str {
let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}"));
let rest = &xml[i..];
let j = rest.find("<p:nvPr").unwrap_or(rest.len());
&rest[..j]
}
#[test]
fn lock_and_hidden_round_trip() {
let p = deck();
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
let s = &q.slides[0];
for (name, locked, hidden) in [
("Locked", true, false),
("Free", false, false),
("Hidden text", true, true),
("Group", true, false),
("Child locked", true, false),
("Child free", false, false),
("Child hidden", false, true),
("Pic", true, false),
("Conn", true, false),
("Tbl", true, false),
] {
let sh = find(s, name);
assert_eq!((sh.locked, sh.hidden), (locked, hidden), "{name}");
}
// The stacking order of the slide and of the group survives too.
let names: Vec<&str> = s.shapes.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]);
let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect();
assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]);
}
#[test]
fn lock_writes_powerpoints_full_attribute_set() {
let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export"));
let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#;
assert!(nv_of(&xml, "Locked").contains(&format!("<a:spLocks {sp}/>")), "{}", nv_of(&xml, "Locked"));
assert!(nv_of(&xml, "Hidden text").contains(&format!("<a:spLocks {sp}/>")));
assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#));
assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free"));
assert!(nv_of(&xml, "Group").contains(r#"<a:grpSpLocks noGrp="1" noUngrp="1" noRot="1" noMove="1" noResize="1"/>"#));
assert!(nv_of(&xml, "Conn").contains(&format!("<a:cxnSpLocks {sp}/>")), "{}", nv_of(&xml, "Conn"));
assert!(nv_of(&xml, "Pic").contains(&format!(r#"<a:picLocks noChangeAspect="1" {sp} noCrop="1"/>"#)), "{}", nv_of(&xml, "Pic"));
assert!(nv_of(&xml, "Tbl").contains(r#"<a:graphicFrameLocks noGrp="1" noDrilldown="1" noMove="1" noResize="1"/>"#));
}
/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's
/// partial lock), or the set without `noGrp`, isn't locked.
#[test]
fn partial_locks_are_not_lock() {
let mut p = defaults::new_presentation(None);
let a = shape(&mut p, "A", 0.0);
let b = shape(&mut p, "B", 100.0);
let c = shape(&mut p, "C", 200.0);
let mut d = shape(&mut p, "D", 300.0);
d.locked = true;
if let Some(s) = p.slides.first_mut() {
std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d];
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
let xml = String::from_utf8(slide.1.clone()).expect("utf8");
let swap = |xml: String, name: &str, locks: &str| {
let i = xml.find(&format!("name=\"{name}\"")).expect("name");
let j = i + xml[i..].find("<p:cNvSpPr").expect("cNvSpPr");
let k = j + xml[j..].find("<p:nvPr").expect("nvPr");
format!("{}<p:cNvSpPr>{locks}</p:cNvSpPr>{}", &xml[..j], &xml[k..])
};
let xml = swap(xml, "A", r#"<a:spLocks noMove="1" noResize="1"/>"#);
let xml = swap(
xml,
"B",
r#"<a:spLocks noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1"/>"#,
);
let xml =
swap(xml, "C", r#"<a:spLocks noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeShapeType="1"/>"#);
slide.1 = xml.into_bytes();
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
let s = &q.slides[0];
assert!(!find(s, "A").locked);
assert!(!find(s, "B").locked);
assert!(!find(s, "C").locked);
assert!(find(s, "D").locked);
}
/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked.
#[test]
fn empty_locks_element_is_unlocked() {
let mut p = defaults::new_presentation(None);
let a = shape(&mut p, "A", 0.0);
if let Some(s) = p.slides.first_mut() {
std::sync::Arc::make_mut(s).shapes = vec![a];
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("<p:cNvSpPr/>", "<p:cNvSpPr><a:spLocks/></p:cNvSpPr>", 1);
slide.1 = xml.into_bytes();
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert!(!find(&q.slides[0], "A").locked);
}
-98
View File
@@ -1,98 +0,0 @@
//! Embedded (OLE) objects and their preview pictures, as other versions of PowerPoint write them.
use std::io::{Cursor, Read, Write};
use deckcraft_model::{ShapeKind, defaults};
fn png() -> Vec<u8> {
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
.write_to(&mut out, image::ImageFormat::Png)
.expect("png");
out.into_inner()
}
/// An exported deck with `parts` added (or replaced) and `edit` applied to every part's text.
fn with_parts(parts: &[(&str, Vec<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let name = f.name().to_string();
if parts.iter().any(|(n, _)| *n == name) {
continue;
}
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
if name.ends_with(".xml") || name.ends_with(".rels") {
d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
for (name, data) in parts {
out.start_file(*name, o).expect("start");
out.write_all(data).expect("write");
}
out.finish().expect("finish").into_inner()
}
/// PowerPoint 2007 style: `p:oleObj spid` with no `p:pic`; the preview is the `v:imagedata` of the
/// VML shape with that id in the slide's legacy drawing part.
fn ole_deck(spid_in_vml: &str) -> Vec<u8> {
let vml = format!(
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<v:shape id="_x0000_s9999" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rId7" o:title=""/></v:shape>
<v:shape id="{spid_in_vml}" style='position:absolute;left:0;top:0;width:2in;height:1in'><v:imagedata o:relid="rId1" o:title=""/></v:shape>
</xml>"#
);
let vml_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/></Relationships>"#;
let frame = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s681987" name="Slide" r:id="rIdOle" imgW="4664160" imgH="3497400" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
with_parts(
&[
("ppt/drawings/vmlDrawing1.vml", vml.into_bytes()),
("ppt/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes().to_vec()),
("ppt/media/preview.png", png()),
("ppt/media/other.png", vec![1, 2, 3]),
("ppt/embeddings/oleObject1.bin", vec![0; 16]),
],
|name, s| {
if name == "ppt/slides/slide1.xml" {
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
s.replacen(
"</Relationships>",
r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
1,
)
} else {
s
}
},
)
}
fn object_preview(bytes: &[u8]) -> Option<Vec<u8>> {
let p = deckcraft_pptx::import(bytes).expect("import");
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
match &s.kind {
ShapeKind::Opaque { preview, label, .. } => {
assert_eq!(label, "PowerPoint.Slide.8");
preview.and_then(|m| p.media(m)).map(|m| m.data.to_vec())
}
k => panic!("{k:?}"),
}
}
#[test]
fn an_old_ole_object_shows_its_vml_preview() {
assert_eq!(object_preview(&ole_deck("_x0000_s681987")), Some(png()), "the VML shape with the object's spid");
}
#[test]
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
}
-329
View File
@@ -1,329 +0,0 @@
//! The pasteboard: what the editor draws around the slide.
//!
//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
//! on the area around it, at full strength and with their effects (master and layout graphics
//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
//!
//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::style::{Effects, Line};
use deckcraft_model::{Presentation, Shape, ShapeKind};
use kurbo::Rect;
use crate::MAX_SIDE;
/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
/// hostile files can't blow the pasteboard up.
const MAX_SPILL: f64 = 2000.0;
/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
/// that is wider than the window re-renders only when a new tile comes into view.
pub const TILE: i64 = 256;
/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
let mut s: f64 = 0.0;
if let Some(l) = line
&& l.fill.as_ref().is_some_and(|f| !f.is_none())
{
// Arrowheads reach up to a few line widths past the end point.
s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
}
if let Some(e) = effects {
let big = fin(w).max(fin(h));
if let Some(sh) = &e.outer_shadow {
let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
}
if let Some(g) = &e.glow {
s = s.max(fin(g.radius));
}
if let Some(r) = &e.reflection {
s = s.max(fin(h) + fin(r.dist));
}
}
s.min(MAX_SPILL)
}
/// Slide-space box a top-level shape draws into, its spill included.
fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
if s.hidden {
return None;
}
let x: Xfrm = resolve::xfrm(rctx, s);
if !x.is_finite() {
return None;
}
let b = match &s.kind {
ShapeKind::Ink { strokes } => {
let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite());
let (px, py, _) = pts.next()?;
let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py)));
let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL);
return Some(r.inflate(w, w));
}
_ => x.bounds(),
};
let (line, _) = resolve::line(rctx, s);
let (fx, _) = resolve::effects(rctx, s);
let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
Some(b.inflate(d, d))
}
fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r))
}
fn slide_rect(pres: &Presentation) -> Rect {
Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
}
/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
let page = slide_rect(pres);
let Some(slide) = pres.slides.get(index) else { return page };
let Some(rctx) = Ctx::for_slide(pres, slide) else { return page };
let mut r = union_boxes(&rctx, &slide.shapes, page);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r);
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(pres, rctx.master, l);
r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
}
r
}
/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
let page = slide_rect(pres);
let Some(m) = pres.masters.get(master) else { return page };
match layout.and_then(|i| m.layouts.get(i)) {
Some(l) => {
let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
if l.show_master_shapes {
r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r);
}
r
}
None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
}
}
/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Region {
pub x: i32,
pub y: i32,
pub w: u32,
pub h: u32,
}
impl Region {
/// The slide itself, `w` × `h` pixels.
pub fn slide(w: u32, h: u32) -> Self {
Region { x: 0, y: 0, w: w.max(1), h: h.max(1) }
}
fn edges(&self) -> (i64, i64, i64, i64) {
let (x, y) = (self.x as i64, self.y as i64);
(x, y, x + self.w as i64, y + self.h as i64)
}
fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) }
}
/// Does this region cover all of `other`?
pub fn contains(&self, other: &Region) -> bool {
let (a, b) = (self.edges(), other.edges());
a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
}
/// Is the region more than the slide (some of the pasteboard is drawn)?
pub fn beyond(&self, slide: (u32, u32)) -> bool {
*self != Region::slide(slide.0, slide.1)
}
/// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
pub fn offset(&self) -> (f64, f64) {
(-(self.x as f64), -(self.y as f64))
}
/// The region's size, for render options' `size`.
pub fn size(&self) -> (u32, u32) {
(self.w, self.h)
}
}
/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
let lim = i32::MAX as f64 / 2.0;
let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None };
Some((
px((r.x0 * scale).floor())? as i64,
px((r.y0 * scale).floor())? as i64,
px((r.x1 * scale).ceil())? as i64,
px((r.y1 * scale).ceil())? as i64,
))
}
/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
/// area is rounded out to whole [`TILE`]s.
pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region {
let page = Region::slide(slide_px.0, slide_px.1);
if !(scale.is_finite() && scale > 0.0) {
return page;
}
let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page };
let (vx0, vy0, vx1, vy1) = visible.edges();
let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE);
let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
if cx0 >= cx1 || cy0 >= cy1 {
return page;
}
let (px0, py0, px1, py1) = page.edges();
Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use deckcraft_model::Shape;
fn black() -> deckcraft_model::ColorRef {
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
}
fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
let mut p = Presentation::default();
let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() });
p
}
#[test]
fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
let p = deck_with(100.0, 100.0, 50.0, 50.0);
assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
}
#[test]
fn shapes_off_the_slide_grow_the_extent() {
let p = deck_with(-200.0, 50.0, 100.0, 80.0);
let e = slide_extent(&p, 0);
assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
assert_eq!(e.y0, 0.0);
assert_eq!(e.x1, p.slide_size.width);
// Missing slides are just the page.
assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height));
}
#[test]
fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true;
let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
assert_eq!(slide_extent(&p, 0), page);
let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
assert_eq!(slide_extent(&p, 0), page);
let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
assert_eq!(slide_extent(&p, 0), page);
}
#[test]
fn effects_count_towards_the_extent() {
let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap();
sh.effects = Some(Effects {
outer_shadow: Some(deckcraft_model::style::Shadow {
color: black(),
blur: 10.0,
dist: 20.0,
dir: 270.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}),
..Default::default()
});
assert!(slide_extent(&p, 0).y0 <= -130.0);
assert_eq!(
spill(
None,
Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }),
1.0,
1.0
),
MAX_SPILL
);
}
#[test]
fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
let mut p = Presentation::default();
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() });
assert!(slide_extent(&p, 0).x0 <= -150.0);
assert!(layout_extent(&p, 0, None).x0 <= -150.0);
assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes);
}
#[test]
fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
let slide = (960, 540);
let page = Region::slide(960, 540);
let view = Region { x: -100, y: -50, w: 1160, h: 640 };
let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
// Off to the left, in view: the region reaches it.
let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
assert!(r.beyond(slide) && r.contains(&page));
assert_eq!(r.offset(), (80.0, 0.0));
// Far off and out of view (the view rounds out to tiles): only what's visible.
let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
assert_eq!(r.x, -256);
// Wholly out of view: the slide.
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page);
// Scale applies; nonsense falls back to the slide.
let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
assert_eq!(r.x, -40);
assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page);
// Huge extents are capped.
let r =
pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 });
assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
}
#[test]
fn scrolling_within_a_tile_keeps_the_region() {
let slide = (2000, 1125);
let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 });
let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 });
assert_eq!(a, b);
}
}
+7 -70
View File
@@ -11,7 +11,6 @@ mod chart;
mod colr;
mod compound;
mod extent;
mod images;
mod metafile;
mod morph_path;
@@ -41,7 +40,6 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::{RenderContext, Resources, peniko};
pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
@@ -235,14 +233,11 @@ pub struct Renderer {
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
bg: Option<(Fill, Option<Rgba>, Affine)>,
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
drawn: Option<Rect>,
}
impl Default for Renderer {
fn default() -> Self {
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None }
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
}
}
@@ -290,38 +285,14 @@ fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
render_slide_drawn(pres, index, opts).0
}
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
RENDERER.with(|r| {
let mut r = r.borrow_mut();
r.drawn = None;
let img = r.slide(pres, slide, index, opts);
(img, r.drawn.take())
})
})) {
Ok((img, drawn)) => {
let k = opts.scale;
let to_slide =
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
let extent = match drawn {
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
_ => page,
};
(img, extent)
}
let Some(slide) = pres.slides.get(index) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
Ok(img) => img,
Err(_) => {
log::error!("rendering slide {index} panicked; showing a blank slide");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
let (w, h) = output_size(pres, opts);
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
}
}
}
@@ -501,14 +472,6 @@ impl Renderer {
(ctx, w, h, view)
}
/// Note that `r` (local space, placed by `m`) was drawn.
fn track(&mut self, m: Affine, r: Rect) {
let b = m.transform_rect_bbox(r);
if b.is_finite() {
self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
}
}
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
}
@@ -695,21 +658,10 @@ impl Renderer {
self.shape(ctx, f, rctx, c, gm, depth + 1);
}
}
ShapeKind::Table(t) => {
// Rows grow with their text: what the cells cover, borders included.
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
}
ShapeKind::Chart(c) => {
self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
chart::draw(ctx, rctx, c, m, x.w, x.h)
}
ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields),
ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h),
ShapeKind::Ink { strokes } => {
for sk in strokes {
let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
for (px, py, _) in &sk.points {
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
}
let mut bp = BezPath::new();
for (i, (px, py, _)) in sk.points.iter().enumerate() {
if i == 0 {
@@ -740,8 +692,6 @@ impl Renderer {
let (fill, fill_ph) = resolve::fill(rctx, s);
let (line, line_ph) = resolve::line(rctx, s);
let (effects, fx_ph) = resolve::effects(rctx, s);
let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
let outline = geo.outline();
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
@@ -767,13 +717,6 @@ impl Renderer {
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
ctx.pop_layer();
}
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
// preview picture, stretched over the frame, as PowerPoint draws it.
ShapeKind::Opaque { preview: Some(media), .. } => {
ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
}
ShapeKind::Media(mc) => {
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
@@ -875,12 +818,6 @@ impl Renderer {
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
let tm = text_transform(m, tr, l.rotation);
for b in l.lines.iter().filter_map(line_bounds) {
// Glyphs reach a little past their carets and line box (italics, swashes).
let pad = b.height() * 0.3;
self.track(tm, b.inflate(pad, pad));
}
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
+1 -14
View File
@@ -297,21 +297,9 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
out
}
/// Draws the table; returns the box its cells cover (table space), which grows with their text.
#[allow(clippy::too_many_arguments)]
pub fn draw(
ctx: &mut RenderContext,
pres: &Presentation,
rctx: &Ctx,
t: &Table,
m: Affine,
_w: f64,
_h: f64,
fields: &dyn deckcraft_text::Fields,
) -> Option<kurbo::Rect> {
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
let style_bg = rctx
.pres
@@ -391,5 +379,4 @@ pub fn draw(
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
let _ = peniko::Fill::NonZero;
}
covered
}
-122
View File
@@ -1146,125 +1146,3 @@ fn broken_metafiles_draw_nothing_and_never_panic() {
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
}
}
#[test]
fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
// An embedded OLE object (or an unknown graphic) draws its preview stretched over its frame,
// as PowerPoint does; with no preview it draws nothing.
let png = {
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([200, 20, 20])))
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
};
for with_preview in [true, false] {
let mut p = Presentation::default();
let media = p.add_media("image1.png", "image/png", png.clone());
let sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
for (x, y) in [(110, 110), (300, 200), (490, 290)] {
let px = img.pixel(x, y);
let red = px[0] > 180 && px[1] < 60 && px[2] < 60;
assert_eq!(red, with_preview, "({x}, {y}) {px:?}");
}
assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame");
}
}
/// #270: a filled shape half off the slide's left edge, and one wholly below it.
fn off_slide_deck() -> Presentation {
let half = Shape {
id: ShapeId(2700),
xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let below = Shape {
id: ShapeId(2701),
xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),
..Default::default()
};
deck_with(vec![half, below])
}
fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool {
(px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255
}
#[test]
fn shows_exports_and_thumbnails_clip_to_the_slide() {
let p = off_slide_deck();
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (960, 540));
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
// The on-slide half is there, nothing else is drawn outside the slide.
assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150));
assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]);
}
#[test]
fn the_editor_pasteboard_draws_shapes_off_the_slide() {
let p = off_slide_deck();
let extent = slide_extent(&p, 0);
let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 });
assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}");
let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() };
let (img, drawn) = render_slide_drawn(&p, 0, &opts);
assert_eq!((img.width, img.height), region.size());
let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32);
let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2);
// Both halves of the straddling shape, at full strength, and the shape below the slide.
assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150));
assert!(near(at(50, 150), a1), "{:?}", at(50, 150));
assert!(near(at(450, 630), a2), "{:?}", at(450, 630));
// The pasteboard itself stays clear (the UI's own colour shows through); the slide is white.
assert_eq!(at(-50, 50), [0, 0, 0, 0]);
assert_eq!(at(200, 300), [255, 255, 255, 255]);
// The drawing's reach, in slide space.
assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}");
}
#[test]
fn master_graphics_off_the_slide_show_on_the_pasteboard() {
let mut p = Presentation::default();
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
m.shapes.push(Shape {
id: ShapeId(2702),
xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)),
..Default::default()
});
let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 });
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() });
let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3);
assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3));
}
#[test]
fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() };
let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive");
for para in &mut t.paragraphs {
for r in &mut para.runs {
r.props.size = Some(40.0);
}
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
// The box estimate doesn't see the text below the slide; the render does.
assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0);
let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
assert!(drawn.y1 > 600.0, "{drawn:?}");
// A slide with nothing off it reaches just the slide.
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
}
-27
View File
@@ -1831,33 +1831,6 @@ impl TextLayout {
}
out
}
/// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
/// visual span, a little below each line's baseline (PowerPoint underlines the composition
/// in place). A composition that wraps gets one underline per line.
pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
let (s, e) = if a <= b { (a, b) } else { (b, a) };
let mut out = vec![];
if s == e {
return out;
}
for l in &self.lines {
let lp = Pos { para: l.para, ch: l.start };
let le = Pos { para: l.para, ch: l.end };
if le <= s || lp >= e {
continue;
}
let from = if s > lp { s.ch } else { l.start };
let to = if e < le { e.ch } else { l.end };
// Under the baseline, in the descent, never below the line.
let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
for (x0, x1) in l.spans(from, to) {
if x1 > x0 {
out.push((x0, x1, y));
}
}
}
out
}
/// Vertical caret movement: the position on the line above/below closest to `x`.
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
let Some(li) = self.line_of(pos) else { return pos };
-30
View File
@@ -151,36 +151,6 @@ fn caret_and_hit_roundtrip() {
assert_eq!(r.len(), 1);
}
/// #101: the text an input method is composing is underlined in place, under the baseline of each
/// line it occupies, and only over the composed characters.
#[test]
fn composition_is_underlined_per_line() {
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
let l = lay(&p, &sh, 600.0, 100.0);
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
let u = l.composition_underlines(a, b);
assert_eq!(u.len(), 1, "{u:?}");
let (x0, x1, y) = u[0];
let (cx0, ..) = l.caret(a).unwrap();
let (cx1, ..) = l.caret(b).unwrap();
assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}");
let line = &l.lines[0];
assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom);
// Reversed ends give the same; an empty composition, nothing.
assert_eq!(l.composition_underlines(b, a), u);
assert!(l.composition_underlines(a, a).is_empty());
// Wrapped: one underline per line.
let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0);
let l = lay(&p, &sh, 120.0, 400.0);
assert!(l.lines.len() >= 2);
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
assert!(u.len() >= 2, "{u:?}");
assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}");
// Positions past the text never panic.
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
}
#[test]
fn alignment_center_and_right() {
let (p, mut sh) = setup("Hi", 400.0, 100.0);
+11 -100
View File
@@ -1,10 +1,6 @@
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
//! previews, the text caret and text selection).
//!
//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the
//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved;
//! the slide show, exports and thumbnails clip to the slide.
use deckcraft_engine::tools::{Hit, box_handles};
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
@@ -112,26 +108,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
crate::media::gif_repaint(ui.ctx(), &gifs);
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
let st = app.session.active();
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
let visible = visible_region(avail, slide_rect, pixel);
let tex = match st.map(|d| (d.selection.target, d)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
None => None,
};
if let Some((tex, region)) = tex {
let at = Rect::from_min_size(
slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y),
vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y),
);
app.canvas_region = Some(at);
painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
if let Some(tex) = tex {
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
app.perf.render_ms = app.textures.last_render_ms;
media_frames(app, &painter, xf);
@@ -164,69 +152,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
ime_output(app, ui, xf);
media_bar(app, ui, xf);
gif_buttons(app, ui, xf, &gifs);
context_menu(app, &resp);
}
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
/// top-left corner.
fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region {
let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 };
let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor());
let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil());
let lim = i32::MAX as f64 / 4.0;
let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim));
deckcraft_render::Region {
x: x0 as i32,
y: y0 as i32,
w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32,
h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32,
}
}
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
/// nothing to type into.
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
let st = app.session.active()?;
if let Some(ts) = &st.selection.text {
if ts.notes {
return None;
}
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
}
let sh = st.shape(ts.shape)?;
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
}
if st.selection.shapes.len() != 1 {
return None;
}
let sh = st.shape(*st.selection.shapes.first()?)?;
if sh.is_line() {
return None;
}
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
}
/// Turn the input method on for the slide while it takes typing, with its candidate window at
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
let r = to_global * r;
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
}
/// Play/pause on an animated GIF under the pointer or selected on its own.
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
let Some(d) = app.session.active() else { return };
@@ -310,17 +240,14 @@ fn master_tex(
master: usize,
layout: Option<usize>,
px: (u32, u32),
visible: deckcraft_render::Region,
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
) -> Option<egui::TextureHandle> {
let img = deckcraft_render::render_layout(
doc,
master,
layout,
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
&deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
);
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
@@ -461,13 +388,6 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
} else {
poly(painter, corners, sel_stroke);
}
if sh.locked && !editing_this {
// Locked: greyed handles that don't respond, no rotate or adjust handles.
for h in box_handles(&x).0 {
painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill));
}
continue;
}
if st.selection.shapes.len() == 1 || !editing_this {
let (hs, rot) = box_handles(&x);
let rot_s = xf.to_screen(rot);
@@ -502,16 +422,12 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
}
// An input method's composition: underlined in place, thicker under the part being converted.
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
}
if !ts.is_range() {
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
// An empty placeholder shows the caret where typing begins too.
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
}
}
}
@@ -647,11 +563,6 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
});
let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked));
if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() {
let _ = app.run("shape.lock", json!({"locked": !locked}));
ui.close();
}
if ui.button("Link…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
ui.close();
-17
View File
@@ -147,23 +147,6 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
let Some(t) = s("text") else { return err("missing `text`") };
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
}
"ui.ime" => {
// An input method's event, through egui like a real one: it reaches the slide's text
// or a focused text field (#101).
let ev = if let Some(t) = s("commit") {
egui::ImeEvent::Commit(t.into())
} else if let Some(t) = s("preedit") {
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..y)
});
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
} else {
return err("missing `preedit` or `commit`");
};
app.synthetic.push(egui::Event::Ime(ev));
ok(json!({"queued": true}))
}
"ui.tool.select" => {
let Some(name) = s("tool") else { return err("missing `tool`") };
match tool_kind(name, s("preset")) {
+7 -17
View File
@@ -284,8 +284,6 @@ icons! {
Info => "info",
Warning => "warning",
Lock => "lock",
/// Open padlock (Selection pane: an object that isn't locked).
Unlock => "unlock",
Eye => "eye",
EyeOff => "eye-off",
Close => "close",
@@ -2264,30 +2262,22 @@ fn draw(p: &Pen<'_>, icon: Icon) {
p.seg((12.0, 9.0), (12.0, 14.0), D);
p.dot((12.0, 17.2), 1.2, D);
}
// Selection pane toggles: monochrome outlines in the ink colour.
I::Lock => {
let mut sh = vec![(7.5, 10.5), (7.5, 8.0)];
sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0));
sh.push((16.5, 10.5));
p.line(&sh, K);
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K);
p.seg((12.0, 14.5), (12.0, 17.5), K);
}
I::Unlock => {
// The shackle swung open: its left leg out of the body, raised.
let mut sh = vec![(12.0, 10.5), (12.0, 6.0)];
sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0));
sh.push((21.0, 8.5));
p.line(&sh, K);
p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K);
p.seg((9.75, 14.5), (9.75, 17.5), K);
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K);
p.dot((12.0, 15.0), 1.5, D);
p.seg((12.0, 15.5), (12.0, 18.3), D);
}
I::Eye | I::EyeOff => {
let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16);
e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1));
let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16);
e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1));
e.pop();
p.shape(&e, N, K);
p.circle((12.0, 12.0), 3.2, N, K);
p.circle((12.0, 12.0), 3.4, B, N);
p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W });
if icon == I::EyeOff {
p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1);
}
-217
View File
@@ -1,217 +0,0 @@
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
//! real glyphs rather than boxes.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use egui::{Event, FontId, ImeEvent};
use serde_json::json;
use crate::{Services, SlideApp};
use deckcraft_engine::Session;
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, Services::default()),
);
// The app keeps requesting repaints, so step frames instead of run().
h.run_steps(3);
h
}
fn preedit(text: &str) -> Event {
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
}
fn title_text(app: &SlideApp) -> String {
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
fn undo_len(app: &SlideApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
/// is on while the slide's text is edited, with its window at the insertion point.
#[test]
fn korean_composes_in_a_slide_text_box() {
let mut h = harness(Session::with_new());
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
h.run_steps(2);
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
let slide = h.state().slide_rect.unwrap();
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
let caret0 = ime.rect;
let undo0 = undo_len(h.state());
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
h.event(preedit(pre));
h.run_steps(1);
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
let st = h.state().session.active().unwrap();
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
}
h.run_steps(1);
let ime = h.output().platform_output.ime.unwrap();
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
h.run_steps(2);
assert_eq!(title_text(h.state()), "한글");
assert!(h.state().session.active().unwrap().composition.is_none());
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
// Escape cancels a composition: an empty preedit, and the text is as it was.
h.event(preedit("ㅁ"));
h.event(preedit(""));
h.run_steps(2);
assert_eq!(title_text(h.state()), "한글");
assert_eq!(undo_len(h.state()), undo0 + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(1);
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
}
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
/// composition the input method left open.
#[test]
fn japanese_conversion_shows_the_active_part() {
let mut h = harness(Session::with_new());
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
h.run_steps(2);
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
let undo0 = undo_len(h.state());
h.key_press(egui::Key::End);
h.run_steps(1);
assert_eq!(title_text(h.state()), "日本ご");
assert!(h.state().session.active().unwrap().composition.is_none());
assert_eq!(undo_len(h.state()), undo0 + 1);
}
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
/// the input method is on for it.
#[test]
fn composing_on_a_selected_shape_starts_editing_it() {
let mut h = harness(Session::with_new());
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
let id = r["id"].as_u64().unwrap();
h.state_mut().run("text.exit", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().session.active().unwrap().selection.text.is_none());
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
h.event(preedit("新"));
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
h.run_steps(2);
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(v["text"], "新しい");
}
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
#[test]
fn korean_composes_in_the_notes_pane() {
let mut h = harness(Session::with_new());
let id = egui::Id::new("notes_edit");
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
assert!(h.ctx.memory(|m| m.has_focus(id)));
let undo0 = undo_len(h.state());
for pre in ["ㄴ", "노", "놑", "노트"] {
h.event(preedit(pre));
h.run_steps(1);
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
}
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
let slide = h.state().slide_rect.unwrap();
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
h.run_steps(1);
assert_eq!(h.state().notes_buf.2, "노트");
// Focus leaves the field (Escape): the notes are committed as one step.
h.key_press(egui::Key::Escape);
h.run_steps(2);
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
assert_eq!(notes, "노트");
assert_eq!(undo_len(h.state()), undo0 + 1);
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
// Composing, then going to another slide: the notes stay with the slide they were typed for.
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
h.run_steps(2);
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.event(preedit("ㅇ"));
h.event(Event::Ime(ImeEvent::Commit("요".into())));
h.run_steps(1);
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
h.run_steps(2);
let d = h.state().session.active().unwrap();
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
assert_eq!(d.doc.slides[1].notes_text(), "");
}
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
fn system_has(s: &str) -> bool {
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
}
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
#[test]
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
let h = harness(Session::with_new());
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
let mut s = Session::with_new();
let korean = "한국어 발표 자료";
let japanese = "日本語のスライド";
let chinese = "简体中文演示文稿";
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
s.execute("section.add", &json!({"name": chinese})).unwrap();
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
let mut h = harness(s);
h.state_mut().run("view.outline", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().session.active().unwrap().title().contains(korean));
let mut checked = 0;
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
if !system_has(text) {
eprintln!("skipped {text:?}: no font on this machine draws it");
continue;
}
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
}
checked += 1;
}
if cfg!(windows) || cfg!(target_os = "macos") {
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
}
// Text typed later (a dialog field, a new comment) is looked at too.
let typed = "ไทย";
h.event(Event::Text(typed.into()));
h.run_steps(2);
if system_has(typed) {
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
}
}
+1 -168
View File
@@ -16,7 +16,6 @@ pub mod media;
pub mod menus;
pub mod panes;
pub mod ribbon;
pub mod selpane;
pub mod show;
pub mod sorter;
pub mod status;
@@ -24,7 +23,6 @@ pub mod textures;
pub mod theme;
pub mod thumbs;
pub mod toast;
pub mod uifonts;
pub mod widgets;
use std::sync::mpsc::{Receiver, Sender};
@@ -195,8 +193,6 @@ pub struct SlideApp {
pub canvas_rect: Option<egui::Rect>,
pub canvas_scale: f32,
pub slide_rect: Option<egui::Rect>,
/// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen.
pub canvas_region: Option<egui::Rect>,
pub palette: Option<(String, usize)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
@@ -216,12 +212,8 @@ pub struct SlideApp {
/// Quit was confirmed (changes saved or discarded): let the window close.
pub quit_confirmed: bool,
pub(crate) notes_buf: (u64, usize, String),
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
pub ui_fonts: uifonts::UiFonts,
/// Thumbnail pane drag (slide index, current drop index).
pub(crate) thumb_drag: Option<(usize, usize)>,
/// Selection pane: folded groups, renaming, drag and drop.
pub selpane: selpane::State,
}
impl SlideApp {
@@ -244,7 +236,6 @@ impl SlideApp {
canvas_rect: None,
canvas_scale: 1.0,
slide_rect: None,
canvas_region: None,
palette: None,
integrated_titlebar: false,
home: None,
@@ -259,9 +250,7 @@ impl SlideApp {
last_recovery: 0.0,
quit_confirmed: false,
notes_buf: (0, usize::MAX, String::new()),
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
}
}
@@ -553,7 +542,7 @@ impl SlideApp {
ctx.set_zoom_factor(scale);
}
if !self.styled {
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
theme::install_fonts(ctx);
self.styled = true;
self.restyle = true;
} else {
@@ -576,9 +565,6 @@ impl SlideApp {
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
if self.fonts_ready {
self.note_ui_text(ctx);
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
// Closing the window with unsaved changes asks first.
@@ -610,52 +596,6 @@ impl SlideApp {
}
}
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
/// presentations' names and text, recent files, and what is typed or composed this frame.
fn note_ui_text(&mut self, ctx: &egui::Context) {
let typed: String = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
_ => None,
})
.collect()
});
self.ui_fonts.note(ctx, &typed);
for d in self.session.documents() {
self.ui_fonts.note(ctx, &d.title());
}
for r in &self.ui.recent {
self.ui_fonts.note(ctx, r);
}
if let Some(st) = self.session.active() {
self.ui_fonts.note_doc(ctx, st);
}
self.ui_fonts.apply(ctx);
}
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
/// replaced on each update; the commit replaces it as one undo step.
fn ime_event(&mut self, e: &egui::ImeEvent) {
let r = match e {
egui::ImeEvent::Preedit { text, active_range_chars } => {
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
}
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
#[allow(deprecated)]
egui::ImeEvent::Disabled => {
self.session.end_composition();
Ok(Value::Null)
}
_ => Ok(Value::Null),
};
if let Err(e) = r {
self.set_status(e.to_string());
}
}
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
@@ -703,7 +643,6 @@ impl SlideApp {
self.set_status(e.to_string());
}
}
egui::Event::Ime(ime) => self.ime_event(&ime),
egui::Event::Copy => {
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
@@ -947,9 +886,6 @@ fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
@@ -1042,107 +978,4 @@ mod tests {
app.run("app.home", json!({})).unwrap();
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
}
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
let app = harness.state_mut();
// Zoomed out, the canvas shows plenty of pasteboard around the slide.
app.ui.zoom = Some(0.4);
app.session.prefs.snap_to_grid = false;
app.session.prefs.smart_guides = false;
harness
}
fn selected(app: &SlideApp) -> Vec<u64> {
app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect()
}
fn shape_x(app: &SlideApp, id: u64) -> f64 {
let st = app.session.active().unwrap();
st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x
}
/// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where
/// they can be selected and moved.
#[test]
fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() {
let mut harness = canvas_harness();
let off =
harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"]
.as_u64()
.unwrap();
harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap();
harness.run_steps(3);
let app = harness.state();
let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale);
// The texture reaches past the slide's right edge to the shape's, and no further left/up.
assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}");
assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}");
let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k);
assert!(app.canvas_rect.unwrap().contains(centre));
// Drawn at full strength on the pasteboard (when a GPU renderer is available here).
if let Ok(img) = harness.render() {
let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0;
let shape = px(centre);
let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k));
let doc = &harness.state().session.active().unwrap().doc;
let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4;
assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}");
assert_ne!(shape, board);
}
// Click selects it.
harness.hover_at(centre);
harness.run_steps(1);
harness.drag_at(centre);
harness.run_steps(1);
harness.drop_at(centre);
harness.run_steps(2);
assert_eq!(selected(harness.state()), vec![off]);
// Drag moves it, further off the slide.
let to = centre + egui::vec2(40.0, 0.0);
harness.hover_at(centre);
harness.run_steps(1);
harness.drag_at(centre);
harness.run_steps(1);
for i in 1..=4 {
harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0));
harness.run_steps(1);
}
harness.drop_at(to);
harness.run_steps(2);
let x = shape_x(harness.state(), off);
assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}");
// The pasteboard followed it.
let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap());
assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}");
// Idle frames don't render the canvas again.
let n = harness.state().textures.canvas_renders;
harness.run_steps(3);
assert_eq!(harness.state().textures.canvas_renders, n);
}
/// A slide with nothing off it renders just the slide, as before.
#[test]
fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() {
let mut harness = canvas_harness();
harness.run_steps(3);
let app = harness.state();
let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap());
assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}");
let n = app.textures.canvas_renders;
harness.run_steps(3);
assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again");
}
}
+67 -1
View File
@@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"format" => format_shape(app, ui),
"background" => background(app, ui),
"animation" => animation(app, ui),
"selection" => crate::selpane::show(app, ui),
"selection" => selection(app, ui),
"comments" => comments(app, ui),
"designer" => designer(app, ui),
_ => {}
@@ -549,6 +549,72 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
let _ = Stroke::NONE;
}
fn selection(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let shapes: Vec<(u32, String, bool, &'static str)> =
st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect();
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
ui.horizontal(|ui| {
if ui.button("Show All").clicked() {
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
run(app, "shape.visible", json!({"ids": ids, "visible": true}));
}
if ui.button("Hide All").clicked() {
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
run(app, "shape.visible", json!({"ids": ids, "visible": false}));
}
});
ui.separator();
let n = shapes.len();
for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let sel = selected.contains(&id);
if sel || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover });
}
ui.painter().text(
pos2(r.min.x + 8.0, r.center().y),
Align2::LEFT_CENTER,
&name,
theme::font(12.5),
if hidden { t.text_faint } else { t.text },
);
ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint);
let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false);
let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
if eye_resp.clicked() {
run(app, "shape.visible", json!({"id": id, "visible": hidden}));
} else if resp.clicked() {
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
run(app, "edit.select", json!({"ids": [id], "toggle": add}));
}
if resp.double_clicked() {
let mut d = crate::dialogs::Dialog::new("renameShape");
d.params = json!({"id": id, "name": name});
app.dialog = Some(d);
}
resp.context_menu(|ui| {
if ui.button("Bring Forward").clicked() {
run(app, "arrange.bringForward", json!({"ids": [id]}));
ui.close();
}
if ui.button("Send Backward").clicked() {
run(app, "arrange.sendBackward", json!({"ids": [id]}));
ui.close();
}
if ui.button("Rename…").clicked() {
let mut d = crate::dialogs::Dialog::new("renameShape");
d.params = json!({"id": id, "name": name});
app.dialog = Some(d);
ui.close();
}
});
let _ = (k, n);
}
}
fn comments(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if ui.button("+ New Comment").clicked() {
-412
View File
@@ -1,412 +0,0 @@
//! Selection pane: every object on the slide, front-most first, groups expanded with their
//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among
//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move
//! through the list. Everything it changes goes through engine commands.
use std::collections::HashSet;
use deckcraft_model::Shape;
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
use serde_json::json;
use crate::SlideApp;
use crate::icons::{self, Icon};
use crate::theme::{self, Tokens};
pub const ROW_H: f32 = 24.0;
const INDENT: f32 = 14.0;
/// Pane-only state (nothing here is part of the document).
#[derive(Debug, Default)]
pub struct State {
/// Groups folded shut.
pub collapsed: HashSet<u32>,
/// Object being renamed and the text typed so far.
pub renaming: Option<(u32, String)>,
rename_started: bool,
/// Last row clicked without Shift (the start of a Shift+click run).
pub anchor: Option<u32>,
/// Row being dragged and the drop it would make: (index among siblings, line y).
pub drag: Option<(u32, Option<(usize, f32)>)>,
}
/// One line of the list.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub id: u32,
pub name: String,
pub depth: usize,
pub group: bool,
pub hidden: bool,
/// Inside a hidden group: drawn dimmed.
pub dimmed: bool,
pub locked: bool,
pub parent: Option<u32>,
}
/// The rows as listed: front-most first, children under their group unless it's folded.
pub fn rows(shapes: &[Shape], collapsed: &HashSet<u32>) -> Vec<Row> {
fn rec(v: &[Shape], depth: usize, parent: Option<u32>, dim: bool, collapsed: &HashSet<u32>, out: &mut Vec<Row>) {
if depth > 64 {
return;
}
for sh in v.iter().rev() {
out.push(Row {
id: sh.id.0,
name: sh.name.clone(),
depth,
group: sh.is_group(),
hidden: sh.hidden,
dimmed: dim,
locked: sh.locked,
parent,
});
if sh.is_group() && !collapsed.contains(&sh.id.0) {
rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out);
}
}
}
let mut out = vec![];
rec(shapes, 0, None, false, collapsed, &mut out);
out
}
/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's
/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index
/// among the siblings in the file's order (0 = back) and the y of the insertion line.
pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> {
let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect();
let first = others.first()?;
let mut gaps: Vec<f32> = vec![first.1.top()];
gaps.extend(others.iter().map(|(_, r)| r.bottom()));
let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?;
// `g` siblings sit above the line; the dragged object goes behind them.
Some((others.len() - g, *gy))
}
fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) {
let _ = app.run(id, p);
}
/// Each row is a widget with an id of its own (keyboard focus sits on the selected row).
fn row_id(id: u32) -> Id {
Id::new(("selection_pane_row", id))
}
fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label));
if enabled && resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
}
ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled);
resp.on_hover_text(label)
}
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let rows = rows(st.shapes(), &app.selpane.collapsed);
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
let has_sel = !selected.is_empty();
ui.horizontal(|ui| {
if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() {
run(app, "selection.showAll", json!({}));
}
if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() {
run(app, "selection.hideAll", json!({}));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() {
run(app, "arrange.sendBackward", json!({}));
}
if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() {
run(app, "arrange.bringForward", json!({}));
}
});
});
ui.add_space(6.0);
// The list has the keyboard while one of its rows has focus.
let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id));
if let Some(f) = focused_row {
ui.memory_mut(|m| {
m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true })
});
}
let mut rects: Vec<Rect> = Vec::with_capacity(rows.len());
let width = ui.available_width();
for (k, row) in rows.iter().enumerate() {
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag());
rects.push(r);
let sel = selected.contains(&row.id);
resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name));
let painter = ui.painter_at(r);
if sel {
painter.rect_filled(r, CornerRadius::same(3), t.selected);
painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside);
} else if resp.hovered() {
painter.rect_filled(r, CornerRadius::same(3), t.hover);
}
let x0 = r.min.x + 4.0 + INDENT * row.depth as f32;
// Group chevron.
if row.group {
let open = !app.selpane.collapsed.contains(&row.id);
let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0));
let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click());
let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name);
cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false);
if cresp.clicked() {
if open {
app.selpane.collapsed.insert(row.id);
} else {
app.selpane.collapsed.remove(&row.id);
}
}
}
let name_x = x0 + 16.0;
let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0));
let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0));
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0));
let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone());
if let Some(mut buf) = renaming {
let edit_id = ui.id().with(("rename", row.id));
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0)));
if !app.selpane.rename_started {
out.request_focus();
if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) {
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()));
s.cursor.set_char_range(Some(all));
s.store(ui.ctx(), edit_id);
}
app.selpane.rename_started = true;
}
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
if out.lost_focus() || enter || esc {
app.selpane.renaming = None;
app.selpane.rename_started = false;
if !esc && buf.trim() != row.name && !buf.trim().is_empty() {
run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()}));
}
ui.memory_mut(|m| m.request_focus(row_id(row.id)));
} else if let Some((_, s)) = app.selpane.renaming.as_mut() {
*s = buf;
}
} else {
let color = if row.dimmed { t.text_faint } else { t.text };
painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color);
}
// Eye and padlock.
let ink = if row.dimmed { t.text_faint } else { t.text_dim };
icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false);
icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false);
let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click());
let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name);
eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label));
let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click());
let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name);
lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label));
if eye_resp.clicked() {
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
} else if lock_resp.clicked() {
run(app, "shape.lock", json!({"id": row.id}));
} else if resp.double_clicked() {
app.selpane.renaming = Some((row.id, row.name.clone()));
app.selpane.rename_started = false;
} else if resp.clicked() {
let m = ui.input(|i| i.modifiers);
click(app, &rows, k, m.command || m.ctrl, m.shift);
ui.memory_mut(|mem| mem.request_focus(row_id(row.id)));
}
if resp.drag_started() {
if !sel {
run(app, "selection.select", json!({"id": row.id}));
}
app.selpane.drag = Some((row.id, None));
}
context_menu(app, &resp, row);
}
// Dragging: the insertion line among the dragged row's siblings.
if let Some((dragged, _)) = app.selpane.drag {
let pointer = ui.input(|i| i.pointer.interact_pos());
let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down());
let target = pointer.and_then(|p| {
let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?;
let blocks = sibling_blocks(&rows, &rects, parent);
drop_target(dragged, &blocks, p.y)
});
app.selpane.drag = Some((dragged, target));
if let Some((_, y)) = target {
let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width);
let p = ui.painter();
let s = Stroke::new(2.0, t.accent);
p.line_segment([pos2(x0, y), pos2(x1, y)], s);
p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s);
p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s);
}
if released {
app.selpane.drag = None;
if let Some((index, _)) = target {
run(app, "arrange.reorder", json!({"id": dragged, "index": index}));
}
}
}
// Keys while the list has focus.
if let Some(f) = focused_row
&& app.selpane.renaming.is_none()
{
keys(app, ui, &rows, &selected, f);
}
// Clicking the empty part of the list doesn't change anything; keep the pane's height.
let rest = ui.available_size();
if rest.y > 0.0 {
ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover());
}
}
/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom.
fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option<u32>) -> Vec<(u32, Rect)> {
let mut out: Vec<(u32, Rect)> = vec![];
let depth_of = |id: Option<u32>| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0);
let depth = depth_of(parent);
let mut current: Option<(u32, Rect)> = None;
for (row, rect) in rows.iter().zip(rects) {
if row.parent == parent {
if let Some(c) = current.take() {
out.push(c);
}
current = Some((row.id, *rect));
} else if row.depth > depth
&& let Some((_, r)) = current.as_mut()
{
*r = r.union(*rect);
} else if let Some(c) = current.take() {
out.push(c);
}
}
if let Some(c) = current {
out.push(c);
}
out
}
fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) {
let Some(row) = rows.get(k) else { return };
if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) {
// A run of rows of the same kind (top level, or inside groups) between the anchor and here.
let (lo, hi) = if a <= k { (a, k) } else { (k, a) };
let top = row.parent.is_none();
let ids: Vec<u32> = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect();
run(app, "selection.select", json!({"ids": ids, "add": ctrl}));
return;
}
run(app, "selection.select", json!({"id": row.id, "toggle": ctrl}));
app.selpane.anchor = Some(row.id);
}
fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) {
let (up, down, home, end, f2, esc) = ui.input(|i| {
(
i.key_pressed(egui::Key::ArrowUp),
i.key_pressed(egui::Key::ArrowDown),
i.key_pressed(egui::Key::Home),
i.key_pressed(egui::Key::End),
i.key_pressed(egui::Key::F2),
i.key_pressed(egui::Key::Escape),
)
});
let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id));
let to = if up {
Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0))
} else if down {
Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0))
} else if home {
Some(0)
} else if end {
Some(rows.len().saturating_sub(1))
} else {
None
};
if let Some(k) = to
&& let Some(r) = rows.get(k)
&& Some(k) != cur
{
run(app, "selection.select", json!({"id": r.id}));
app.selpane.anchor = Some(r.id);
ui.memory_mut(|m| m.request_focus(row_id(r.id)));
}
if f2
&& let [one] = selected
&& let Some(r) = rows.iter().find(|r| r.id == *one)
{
app.selpane.renaming = Some((r.id, r.name.clone()));
app.selpane.rename_started = false;
}
if esc {
ui.memory_mut(|m| m.surrender_focus(row_id(focused)));
}
}
fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) {
resp.context_menu(|ui| {
for (label, cmd) in [
("Bring to Front", "arrange.bringToFront"),
("Bring Forward", "arrange.bringForward"),
("Send Backward", "arrange.sendBackward"),
("Send to Back", "arrange.sendToBack"),
] {
if ui.button(label).clicked() {
run(app, cmd, json!({"ids": [row.id]}));
ui.close();
}
}
ui.separator();
if ui.button("Rename").clicked() {
app.selpane.renaming = Some((row.id, row.name.clone()));
app.selpane.rename_started = false;
ui.close();
}
if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() {
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
ui.close();
}
if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() {
run(app, "shape.lock", json!({"id": row.id}));
ui.close();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn r(id: u32, top: f32) -> (u32, Rect) {
(id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H)))
}
#[test]
fn drop_target_picks_the_nearest_gap() {
// Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1.
let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)];
// Above everything: in front of all three others.
assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0)));
// Between 4 and 3: one sibling above → index 2.
assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0)));
// Below the last other row: the back.
assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0)));
// Nothing else to drop among.
assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None);
}
}
#[cfg(test)]
#[path = "selpane_tests.rs"]
mod ui_tests;
-255
View File
@@ -1,255 +0,0 @@
//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye,
//! the padlock, renaming and the keyboard.
use deckcraft_engine::Session;
use egui::{Modifiers, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open.
fn harness() -> (Harness<'static, SlideApp>, 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"].iter().enumerate() {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 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 g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
ids.push(g);
let mut app = SlideApp::new(s, Services::default());
app.ui.pane = Some("selection".into());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
(h, ids)
}
fn names(h: &Harness<'_, SlideApp>) -> 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(h.state().session.active().unwrap().shapes())
}
fn selected(h: &Harness<'_, SlideApp>) -> Vec<String> {
let st = h.state().session.active().unwrap();
st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect()
}
fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect {
h.get_by_label(name).rect()
}
fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape {
let st = h.state().session.active().unwrap();
let mut found = None;
deckcraft_model::walk(st.shapes(), &mut |s, _| {
if found.is_none() && s.name == name {
found = Some(s);
}
});
found.unwrap()
}
#[test]
fn lists_front_most_first_with_groups_expanded() {
let (h, _) = harness();
let ys: Vec<f32> = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect();
// G is in front, then its children front-most first (E above D), then C, B, A.
let order = ["G", "E", "D", "C", "B", "A"];
let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect();
by_y.sort_by(|a, b| a.1.total_cmp(&b.1));
assert_eq!(by_y.iter().map(|x| x.0).collect::<Vec<_>>(), order);
// Children are indented under the group (the name's row starts at the same x; the group
// has a fold button).
assert!(h.query_by_label("Collapse G").is_some());
assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some());
}
#[test]
fn click_ctrl_click_and_shift_click_select() {
let (mut h, _) = harness();
h.get_by_label("A").click();
h.run_steps(2);
assert_eq!(selected(&h), ["A"]);
h.get_by_label("C").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(selected(&h), ["A", "C"]);
h.get_by_label("A").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(selected(&h), ["C"]);
// Shift+click: the run from the last click (C) down to A.
h.get_by_label("C").click();
h.run_steps(2);
h.get_by_label("A").click_modifiers(Modifiers::SHIFT);
h.run_steps(2);
let mut sel = selected(&h);
sel.sort();
assert_eq!(sel, ["A", "B", "C"]);
// A child of the group.
h.get_by_label("D").click();
h.run_steps(2);
assert_eq!(selected(&h), ["D"]);
}
#[test]
fn eye_and_padlock_toggle_and_undo() {
let (mut h, _) = harness();
h.get_by_label("Hide B").click();
h.run_steps(2);
assert!(shape(&h, "B").hidden);
assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it");
h.get_by_label("Lock G").click();
h.run_steps(2);
assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children");
h.get_by_label("Unlock D").click();
h.run_steps(2);
assert!(!shape(&h, "D").locked && shape(&h, "G").locked);
h.get_by_label("Hide All").click();
h.run_steps(2);
assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden));
h.get_by_label("Show All").click();
h.run_steps(2);
assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden));
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert!(shape(&h, "A").hidden);
}
/// Drag a row with the mouse: press, move in steps, release.
fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for i in 1..=6 {
let t = i as f32 / 6.0;
h.hover_at(from + (to - from) * t);
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(3);
}
#[test]
fn drag_and_drop_restacks_among_siblings() {
let (mut h, _) = harness();
assert_eq!(names(&h), "A B C G[D E]");
// A (bottom row) dragged above G (top row): A goes to the front.
let (a, g) = (row(&h, "A"), row(&h, "G"));
drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0));
assert_eq!(names(&h), "B C G[D E] A");
assert_eq!(selected(&h), ["A"], "the dragged object is selected");
// C dragged to the bottom of the list: the back.
let (c, b) = (row(&h, "C"), row(&h, "B"));
drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0));
assert_eq!(names(&h), "C B G[D E] A");
// D (a child) dragged far above its group stays in the group, at its front.
let (d, a) = (row(&h, "D"), row(&h, "A"));
drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0));
assert_eq!(names(&h), "C B G[E D] A");
// B dropped over the group lands among the top-level objects (here just in front of the
// group), never inside it.
let (b, g) = (row(&h, "B"), row(&h, "G"));
drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0));
assert_eq!(names(&h), "C G[E D] B A");
// Each drop is one undo step.
for _ in 0..4 {
h.state_mut().run("edit.undo", json!({})).unwrap();
}
assert_eq!(names(&h), "A B C G[D E]");
}
#[test]
fn bring_forward_and_send_backward_buttons() {
let (mut h, _) = harness();
h.get_by_label("A").click();
h.run_steps(2);
h.get_by_label("Bring Forward").click();
h.run_steps(2);
assert_eq!(names(&h), "B A C G[D E]");
h.get_by_label("D").click();
h.run_steps(2);
h.get_by_label("Bring Forward").click();
h.run_steps(2);
h.get_by_label("Bring Forward").click();
h.run_steps(2);
assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group");
h.get_by_label("Send Backward").click();
h.run_steps(2);
assert_eq!(names(&h), "B A C G[D E]");
}
#[test]
fn double_click_renames_in_place() {
let (mut h, _) = harness();
let b = row(&h, "B").center();
h.hover_at(b);
h.run_steps(1);
for _ in 0..2 {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
}
h.run_steps(2);
assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming");
// The whole name is selected: typing replaces it.
h.event(egui::Event::Text("Bravo".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id);
assert!(h.state().selpane.renaming.is_none());
// Escape cancels.
h.get_by_label("Bravo").click();
h.run_steps(2);
h.key_press(egui::Key::F2);
h.run_steps(3);
assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object");
h.event(egui::Event::Text("Nope".into()));
h.run_steps(1);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.query_by_label("Bravo").is_some());
assert!(h.query_by_label("Nope").is_none());
}
#[test]
fn arrow_keys_move_through_the_list_and_collapse_hides_children() {
let (mut h, _) = harness();
h.get_by_label("G").click();
h.run_steps(2);
h.key_press(egui::Key::ArrowDown);
h.run_steps(2);
assert_eq!(selected(&h), ["E"]);
h.key_press(egui::Key::ArrowDown);
h.run_steps(2);
h.key_press(egui::Key::ArrowDown);
h.run_steps(2);
assert_eq!(selected(&h), ["C"]);
// The arrows moved through the list: C itself didn't move on the slide.
assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0));
h.key_press(egui::Key::ArrowUp);
h.run_steps(2);
assert_eq!(selected(&h), ["D"]);
h.get_by_label("Collapse G").click();
h.run_steps(2);
assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some());
h.get_by_label("Expand G").click();
h.run_steps(2);
assert!(h.query_by_label("D").is_some());
}
+4 -11
View File
@@ -129,19 +129,12 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
ui.painter()
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
// Keep a local buffer per slide; commit when it changes and the field loses focus.
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
app.notes_buf = (uid, idx, current.clone());
}
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
// Focus can leave in the frame before the field is drawn (Escape, a click on a
// thumbnail): what was typed is still committed, to the slide it was typed for.
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
}
}
if !focused && !was_focused && app.notes_buf.2 != current {
if !focused && app.notes_buf.2 != current {
app.notes_buf.2 = current.clone();
}
let mut buf = app.notes_buf.2.clone();
+10 -73
View File
@@ -1,15 +1,11 @@
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
//!
//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is
//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide).
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_geom::Rect;
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
use deckcraft_render::{Image, Region, RenderOpts};
use deckcraft_render::{Image, RenderOpts};
use egui::{ColorImage, TextureHandle, TextureOptions};
#[derive(Clone, PartialEq)]
@@ -19,15 +15,11 @@ struct Key {
size: (u32, u32),
edit: bool,
extra: u64,
/// The pasteboard region the slide's drawing calls for (canvas only).
want: Region,
}
struct Entry {
key: Key,
tex: TextureHandle,
/// The region the texture covers (at least `key.want`).
region: Region,
// Keep the Arcs alive so their addresses can't be reused while cached.
_slide: Arc<Slide>,
_master: Option<Arc<Master>>,
@@ -37,14 +29,6 @@ struct Entry {
#[derive(Default)]
pub struct Textures {
canvas: Option<Entry>,
/// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s
/// held (kept alive so their addresses can't be reused).
estimate: Option<(Arc<Slide>, Option<Arc<Master>>, Rect)>,
/// How far the last canvas render of a slide (by index) actually drew: text spilling out of
/// its box shows there.
drawn: Option<(usize, Rect)>,
/// Canvas renders so far (tests, performance checks).
pub canvas_renders: u64,
thumbs: HashMap<usize, Entry>,
clock: u64,
pub last_render_ms: f64,
@@ -71,84 +55,41 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
}
fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
match (a, b) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
}
fn threads() -> u16 {
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
}
impl Textures {
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
/// GIF frames.
#[allow(clippy::too_many_arguments)]
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
pub fn slide(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
size: (u32, u32),
visible: Region,
edit: bool,
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
) -> Option<(TextureHandle, Region)> {
) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let estimate = match &self.estimate {
Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r,
_ => {
let r = deckcraft_render::slide_extent(p, index);
self.estimate = Some((slide.clone(), master.clone(), r));
r
}
};
let reach = match self.drawn {
Some((i, d)) if i == index => estimate.union(d),
_ => estimate,
};
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
let key = Key {
slide: Arc::as_ptr(&slide) as usize,
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size,
edit,
extra: extra ^ grayscale as u64,
want,
};
if let Some(e) = &self.canvas
&& e.key == key
{
return Some((e.tex.clone(), e.region));
return Some(e.tex.clone());
}
let t0 = crate::now_ms();
let render = |region: Region| {
let opts =
RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() };
deckcraft_render::render_slide_drawn(p, index, &opts)
};
let (mut img, drawn) = render(want);
self.canvas_renders += 1;
let mut region = want;
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
// region it calls for is known (and is next frame's key): render again only if it shows
// more than this render covered.
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
if !region.contains(&key.want) {
region = key.want;
img = render(region).0;
self.canvas_renders += 1;
}
self.drawn = Some((index, drawn));
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
let img = deckcraft_render::render_slide(p, index, &opts);
self.last_render_ms = crate::now_ms() - t0;
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {
@@ -158,15 +99,13 @@ impl Textures {
}
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
};
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
Some((tex, region))
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
Some(tex)
}
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
pub fn invalidate(&mut self) {
self.canvas = None;
self.estimate = None;
self.drawn = None;
self.thumbs.clear();
}
@@ -182,7 +121,6 @@ impl Textures {
size: (width_px, h),
edit: false,
extra: 0,
want: Region::slide(width_px, h),
};
self.clock += 1;
let clock = self.clock;
@@ -201,8 +139,7 @@ impl Textures {
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
let region = key.want;
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock });
if self.thumbs.len() > 600 {
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
v.sort_by_key(|x| x.1);
+1 -30
View File
@@ -155,18 +155,6 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
pub fn install_fonts(ctx: &egui::Context) {
install_fonts_with(ctx, &[]);
}
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
ctx.set_fonts(font_definitions(extra));
}
/// The interface font definitions; see [`install_fonts_with`].
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
let mut defs = FontDefinitions::default();
let mut names = vec![];
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
@@ -190,24 +178,7 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
add_fallback_faces(&mut defs, extra);
defs
}
/// Add `faces` after every other font of each interface family (the last fallbacks).
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
for f in faces {
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
if defs.font_data.contains_key(&name) {
continue;
}
let mut data = FontData::from_owned(f.data().to_vec());
data.index = f.index();
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
for list in defs.families.values_mut() {
list.push(name.clone());
}
}
ctx.set_fonts(defs);
}
pub fn bold(size: f32) -> egui::FontId {
-202
View File
@@ -1,202 +0,0 @@
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
//! character on screen that they lack, a face that has it is borrowed from the font database (the
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
//! there are no installed fonts, only the embedded faces are used.
//!
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
//! the composition and caption to every text the interface draws from the presentation.
use std::collections::HashSet;
use std::sync::Arc;
use deckcraft_fonts::{FontDb, FontFace};
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
const MAX_FACE_BYTES: usize = 48 << 20;
/// Faces borrowed for the interface, and the characters already looked at.
#[derive(Default)]
pub struct UiFonts {
/// Faces added after the interface fonts, in the order they were found.
pub faces: Vec<Arc<FontFace>>,
/// Characters already looked at (drawn, given a face, or found in no face).
seen: HashSet<char>,
/// The presentation last looked through: (uid, revision).
doc: Option<(u64, u64)>,
/// Faces were added since the fonts were last installed.
changed: bool,
}
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
fn wanted(c: char) -> bool {
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
}
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
/// very large faces are skipped (egui draws neither well).
pub fn faces_for_text(
text: &str,
have: &[Arc<FontFace>],
seen: &mut HashSet<char>,
mut covered: impl FnMut(char) -> bool,
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
) -> Vec<Arc<FontFace>> {
let mut out: Vec<Arc<FontFace>> = Vec::new();
for c in text.chars().filter(|c| wanted(*c)) {
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
continue;
}
let Some(f) = lookup(c) else { continue };
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
continue;
}
out.push(f);
}
out
}
impl UiFonts {
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
return;
}
let font = egui::FontId::proportional(13.0);
let new = ctx.fonts_mut(|f| {
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
});
if !new.is_empty() {
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
self.faces.extend(new);
self.changed = true;
}
}
/// Look through the text of a presentation the interface shows (once per revision): its
/// title, slide text and names, notes, comments, sections and layout names.
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
if self.doc == Some((st.uid, st.revision)) {
return;
}
self.doc = Some((st.uid, st.revision));
let text = ui_text(st);
self.note(ctx, &text);
}
/// Install the interface fonts again when faces were added. True if they were.
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
if !std::mem::take(&mut self.changed) {
return false;
}
crate::theme::install_fonts_with(ctx, &self.faces);
ctx.request_repaint();
true
}
}
/// The text of a presentation that the interface can show (not the slides' pictures of it).
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
let mut out = st.title();
let mut add = |s: &str| {
if s.chars().any(wanted) {
out.push('\n');
out.push_str(s);
}
};
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
for p in &b.paragraphs {
for r in &p.runs {
add(&r.text);
}
}
};
let doc = &st.doc;
add(&doc.props.title);
for sec in &doc.sections {
add(&sec.name);
}
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
deckcraft_model::walk(list, &mut |sh, _| {
add(&sh.name);
add(&sh.title);
add(&sh.descr);
if let Some(t) = &sh.text {
body(t, add);
}
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
for row in &tb.rows {
for cell in &row.cells {
body(&cell.text, add);
}
}
}
});
};
for s in &doc.slides {
add(&s.name);
shapes(&s.shapes, &mut add);
body(&s.notes, &mut add);
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
while let Some(c) = stack.pop() {
add(&c.author);
add(&c.text);
stack.extend(c.replies.iter());
}
}
for m in &doc.masters {
add(&m.name);
for l in &m.layouts {
add(&l.name);
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn face(family: &str) -> Arc<FontFace> {
FontDb::global().face(family, "Regular")
}
/// One face for a whole string, looked up once per character, never for characters the
/// interface already draws, and never twice.
#[test]
fn one_face_per_script_and_each_character_once() {
let inter = face("Inter");
let looked = std::cell::Cell::new(0);
let lookup = |_: char| {
looked.set(looked.get() + 1);
Some(inter.clone())
};
let mut seen = HashSet::new();
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
assert_eq!(got.len(), 1, "one face, not one per character");
// Inter doesn't cover Hangul, so every character was looked up once.
assert_eq!(looked.get(), 5);
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
assert_eq!(looked.get(), 5);
let mut seen = HashSet::new();
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
}
#[test]
fn interface_text_of_a_presentation() {
let mut s = deckcraft_engine::Session::with_new();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
let t = ui_text(s.doc().unwrap());
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
assert!(t.starts_with(&s.doc().unwrap().title()));
}
}
-1
View File
@@ -19,7 +19,6 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
+2 -7
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -225,11 +225,7 @@ Detail in [localization-parity.md](localization-parity.md).
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
(Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h.
- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h.
- **No IME composition in text editing** (CJK input). 3–6 h.
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
@@ -255,7 +251,6 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+2 -3
View File
@@ -1,6 +1,6 @@
# Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
@@ -46,7 +46,7 @@ because it mostly follows from hardware use.
| Pinch to zoom, two-finger gestures | yes | no |
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
| Touch screens (Windows) | yes | basic pointer only |
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
| Dictation | yes (macOS) | no |
## Performance
@@ -71,5 +71,4 @@ outlines every frame (#67, fix open), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME input methods (#101) |
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
+5 -7
View File
@@ -1,6 +1,6 @@
# Localization parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
@@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review.
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|---|---|---|---|---|---|---|---|---|
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
@@ -44,8 +44,7 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
the system (#15), plural and number formatting, and an `xtask` check that every key has a
translation. IME composition landed with #101 (slide text, notes and text fields; one undo step
per composition); the hours that included it are kept until the next re-measure.
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
## Proofing languages
@@ -57,5 +56,4 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
+2 -6
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; ribbon, menus, panes, dialogs and interaction compared with PowerPoint 16.113 observations in `plan/powerpoint/`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
@@ -29,7 +29,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|---|---|---|
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
| Format Background pane | yes | yes |
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
| Selection Pane | yes | yes |
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
| Comments pane (threads) | yes | yes |
| Design Ideas (Designer) | yes (cloud) | no |
@@ -41,7 +41,6 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|---|---|---|
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
| Smart guides (edges, centres, equal spacing) | yes | yes |
| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle |
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
@@ -88,7 +87,4 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |