Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 6e90c955ca Canvas: one caret, drawn at a wrapped line's end after End
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:09:29 -04:00
Brandon Thomas 6a0ca98550 rustfmt 2026-10-12 22:05:56 -04:00
Brandon ThomasandClaude Opus 5.5 4e780d52cc Text: Ctrl+arrows, Ctrl+Backspace/Delete and the other text keys work like PowerPoint (#268)
Observed black-box in PowerPoint for Windows on synthetic text:
- Word stops (crates/model/src/words.rs): words, punctuation runs, symbol
  runs, tabs, line breaks and each CJK character are tokens; trailing spaces
  join the token before. it's, a-b, 3.14, 1,000 are one word; e.g., a_b and
  x@y.com are not. Grapheme clusters never split.
- Ctrl+Left/Right and Ctrl+Shift+Left/Right move by those stops, across
  paragraphs; with a selection they go on from its start/end. In a
  right-to-left paragraph Left and Right swap.
- Ctrl+Backspace deletes to the previous stop; Ctrl+Delete deletes the rest
  of the word, or spaces plus the next word, then applies PowerPoint's smart
  spacing. With a selection both work from its start.
- Ctrl+Up/Down by paragraph, Ctrl+Home/End to the text's ends.
- End on a wrapped line goes past the trailing space and the caret stays
  drawn at that line's end (TextSel::line_end); End again stays, Home
  returns to the same line.
- Ctrl+]/[ and Ctrl+Shift+>/< change font size (egui's Period/Comma/
  bracket key names now match registry shortcuts).
- Shift+F3 cycles lower -> Title -> UPPER; a caret changes its word.
- Double-click selects the word without trailing spaces; triple-click
  includes the paragraph mark.
- Mac keeps Option for words and Cmd for line/text ends (Session::mac_keys).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:05:53 -04:00
Brandon ThomasandClaude Opus 5.5 9d571ef644 Selection Pane: reorder by drag and drop, groups, Show/Hide All and Lock, as in PowerPoint (#133) (#332)
* #133: Selection pane engine: restack inside groups, Show/Hide All, Lock like PowerPoint

- arrange.bringForward/sendBackward/bringToFront/sendToBack and arrange.reorder work on a
  group's children, which stay in their group (as PowerPoint's pane arrows and drag do);
  steps that change nothing leave no undo step.
- selection.select (pane click: Ctrl toggles, children and top-level objects don't mix,
  picking a hidden object shows it), selection.showAll (everything, children too) and
  selection.hideAll (top level).
- shape.lock cascades through groups; locked objects can be selected, formatted and typed
  in, but not moved, sized, rotated, flipped, reshaped or cropped (commands and canvas).
- PPTX: Lock reads and writes PowerPoint's full locking attribute set per element kind
  (observed by saving locked objects from PowerPoint); partial locks aren't Lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Front-most first with groups folding; drag a row to restack among its siblings (accent
insertion line); Bring Forward / Send Backward arrows; Show All / Hide All; per-row eye and
padlock; double-click or F2 renames in place; Ctrl+click toggles, Shift+click selects a run;
arrows, Home, End and Esc in the list; right-click menu. Lock/Unlock on the canvas menu,
greyed handles on locked objects. egui_kittest tests drive the pane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:05:44 -04:00
Brandon ThomasandClaude Opus 5.5 ea3b1159d5 Korean, Japanese and Chinese: input-method composition and interface text (#101) (#331)
* Text: input method composition as commands, one undo step per composition (#101)

text.compose shows an input method's preedit in place at the insertion point and replaces it on
each update; an empty preedit cancels and restores the text and selection. text.commit replaces
the composition with the result as one undo step. Any other edit, key or click settles an open
composition first. The composition is underlined under the baseline (thicker under the part being
converted), and the caret line is measured for empty placeholders too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UI: Korean, Japanese and Chinese input and interface text (#101)

The slide turns the input method on while it takes typing, with the candidate window at the
caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes
syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul,
Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun
Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text.
Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before
focus leaves in the same frame (Escape, another slide). Control channel: ui.ime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:03:42 -04:00
29 changed files with 2990 additions and 215 deletions
Generated
+1
View File
@@ -1063,6 +1063,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"unicode-segmentation",
]
[[package]]
+4
View File
@@ -131,6 +131,10 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
| 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) |
+91 -37
View File
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
cmd!(
"arrange.reorder",
"Reorder Objects",
["Arrange"],
None,
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
has_slide,
reorder
),
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
let ids = targets(s, p)?;
// Already in place (front of its group, say): no change and no undo step.
let mut trial = s.doc()?.shapes().to_vec();
order_tree(&mut trial, &ids, how, 0);
if stacking(&trial) == stacking(s.doc()?.shapes()) {
return ok();
}
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
match how {
2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
list.extend(rest);
list.append(&mut moved);
}
-2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
moved.extend(rest);
*list = moved;
}
1 => {
for i in (0..list.len().saturating_sub(1)).rev() {
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
if a == Some(true) && b == Some(false) {
list.swap(i, i + 1);
}
}
}
_ => {
for i in 1..list.len() {
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
if a == Some(false) && b == Some(true) {
list.swap(i - 1, i);
}
}
}
}
order_tree(list, &ids, how, 0);
Ok(())
})?;
ok()
}
/// Every shape id with its depth, in tree order.
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
let mut out = vec![];
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
out
}
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
if depth > 64 {
return;
}
if list.iter().any(|x| ids.contains(&x.id)) {
order_list(list, ids, how);
}
for sh in list.iter_mut() {
if let Some(children) = sh.children_mut() {
order_tree(children, ids, how, depth + 1);
}
}
}
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
match how {
2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
list.extend(rest);
list.append(&mut moved);
}
-2 => {
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
moved.extend(rest);
*list = moved;
}
1 => {
for i in (0..list.len().saturating_sub(1)).rev() {
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
if a == Some(true) && b == Some(false) {
list.swap(i, i + 1);
}
}
}
_ => {
for i in 1..list.len() {
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
if a == Some(false) && b == Some(true) {
list.swap(i - 1, i);
}
}
}
}
}
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
/// A shape stays in the list that holds it, so a group's child stays in its group.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
let st = s.doc()?;
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
None => return Err(bad("arrange.reorder", "no such shape")),
Some(None) => st.shapes(),
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
};
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
if from == to.min(siblings.len().saturating_sub(1)) {
return Ok(json!({"index": from}));
}
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
let sh = list.remove(i);
list.insert(to.min(list.len()), sh);
Ok(())
})?;
ok()
let at = to.min(list.len());
list.insert(at, sh);
Ok(json!({"index": at}))
})
}
/// Bounds of shapes (rotation included).
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
} else {
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
};
edit_shapes(s, p, "arrange.align", |sh| {
edit_geometry(s, p, "arrange.align", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let b = x.bounds();
let (dx, dy) = match edge.as_str() {
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
targets_pos.push((*id, d));
cur += (if horiz { b.width() } else { b.height() }) + gap;
}
edit_shapes(s, p, "arrange.distribute", |sh| {
edit_geometry(s, p, "arrange.distribute", |sh| {
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
if horiz {
x.x += d;
+37 -7
View File
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
color
),
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
cmd!(
"format.case",
"Change Case",
["Home", "Font"],
Some("Shift+F3"),
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
has_text_or_shapes,
case
),
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
cmd!(
"format.spacing",
@@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
}
fn case(s: &mut Session, p: &Value) -> Result<Value> {
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = t.ordered();
if a == b {
return ok();
let (mut a, mut b) = t.ordered();
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
let collapsed = a == b;
if collapsed {
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
let (wa, wb) = ed::word_at(&body, a);
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
return ok();
}
(a, b) = (wa, wb);
}
let m = mode.clone();
super::text::edit_text(s, move |body, _| {
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
super::text::edit_text(s, move |body, t| {
for pi in a.0..=b.0 {
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
let n = para.char_len();
@@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
pos += len;
}
}
if collapsed {
t.anchor = a;
t.caret = a;
}
})?;
return ok();
}
let mode = if mode == "cycle" {
let all: String =
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
"
",
);
ed::next_case(&all).to_string()
} else {
mode
};
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
+14
View File
@@ -11,6 +11,7 @@ pub mod inspect;
pub mod media;
pub mod merge;
pub mod review;
pub mod selection;
pub mod shape;
pub mod slide;
pub mod table;
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(insert::specs());
v.extend(shape::specs());
v.extend(arrange::specs());
v.extend(selection::specs());
v.extend(merge::specs());
v.extend(text::specs());
v.extend(format::specs());
@@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
})
}
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
/// locked targets are left out, and when every target is locked nothing changes.
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
let ids = targets(s, p)?;
let st = s.doc()?;
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
if !ids.is_empty() && free.is_empty() {
return Err(bad(cmd, "the object is locked"));
}
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
}
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
match sel.target {
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
+126
View File
@@ -0,0 +1,126 @@
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
use deckcraft_model::{Shape, ShapeId};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"selection.select",
"Select",
["Selection Pane"],
None,
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
has_slide,
select
),
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
]
}
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
/// object from the list shows it again.
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
let st = s.doc()?;
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
if valid.is_empty() {
return Err(bad("selection.select", "no such shape"));
}
let kind = valid.first().and_then(in_group);
valid.retain(|i| in_group(i) == kind);
let keep: Vec<ShapeId> = if toggle || add {
let current = st.selection.shapes.clone();
if current.first().is_some_and(|c| in_group(c) != kind) {
// Can't mix objects in groups with top-level ones: nothing changes.
return Ok(json!({"selected": current}));
}
current
} else {
vec![]
};
let mut next = keep;
for i in &valid {
match next.iter().position(|x| x == i) {
Some(k) if toggle => {
next.remove(k);
}
Some(_) => {}
None => next.push(*i),
}
}
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
if reveal.is_empty() {
s.select(|_, sel| {
sel.text = None;
sel.cells = None;
sel.shapes = next.clone();
})?;
} else {
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
for id in &reveal {
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
sh.hidden = false;
}
}
sel.text = None;
sel.cells = None;
sel.shapes = next.clone();
Ok(())
})?;
}
Ok(json!({"selected": s.doc()?.selection.shapes}))
}
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
fn any_hidden(v: &[Shape]) -> bool {
let mut found = false;
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
found
}
if !any_hidden(s.doc()?.shapes()) {
return ok();
}
s.edit(|doc, sel| {
fn rec(v: &mut [Shape], depth: usize) {
for sh in v {
sh.hidden = false;
if depth < 64
&& let Some(c) = sh.children_mut()
{
rec(c, depth + 1);
}
}
}
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
Ok(())
})?;
ok()
}
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
if s.doc()?.shapes().iter().all(|x| x.hidden) {
return ok();
}
s.edit(|doc, sel| {
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
sh.hidden = true;
}
Ok(())
})?;
ok()
}
#[cfg(test)]
#[path = "selection_tests.rs"]
mod tests;
+264
View File
@@ -0,0 +1,264 @@
//! Selection pane: reorder, groups, hide/show, lock and undo.
use serde_json::{Value, json};
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::*;
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
fn deck() -> (Session, Vec<u32>) {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let mut ids = vec![];
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
let x = 20.0 + 110.0 * i as f64;
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
let id = r["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
ids.push(id);
}
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
ids.extend([h, g]);
(s, ids)
}
/// The tree as text, back to front: `A B C G[D H[E F]]`.
fn tree(s: &Session) -> String {
fn rec(v: &[deckcraft_model::Shape]) -> String {
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
}
rec(s.doc().unwrap().shapes())
}
/// (hidden, locked)
fn flags(s: &Session, id: u32) -> (bool, bool) {
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
(sh.hidden, sh.locked)
}
fn selected(s: &Session) -> Vec<u32> {
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
}
fn undo(s: &mut Session) {
s.execute("edit.undo", &json!({})).unwrap();
}
fn bx(s: &mut Session, id: u32) -> Value {
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
}
#[test]
fn bring_forward_and_send_backward_stay_inside_the_group() {
let (mut s, ids) = deck();
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
assert_eq!(tree(&s), "A B C G[D H[E F]]");
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
assert_eq!(tree(&s), "B A C G[D H[E F]]");
// A child moves among its siblings only and stops at the group's front.
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[E F] D]");
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[E F] D]");
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
assert_eq!(tree(&s), "B A C G[H[F E] D]");
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
assert_eq!(tree(&s), "B A C G[D H[F E]]");
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
// Steps that changed nothing left no undo step.
undo(&mut s);
assert_eq!(tree(&s), "B A C G[H[F E] D]");
undo(&mut s);
undo(&mut s);
assert_eq!(tree(&s), "B A C G[D H[E F]]");
}
#[test]
fn reorder_is_the_drag_and_drop_among_siblings() {
let (mut s, ids) = deck();
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
// Drag A to the front of the slide (an index past the end clamps).
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
assert_eq!(tree(&s), "B C G[D H[E F]] A");
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
assert_eq!(tree(&s), "G[D H[E F]] B C A");
// A child is dragged within its group: it never leaves it.
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
assert_eq!(tree(&s), "G[H[E F] D] B C A");
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
assert_eq!(tree(&s), "G[H[F E] D] B C A");
// Dropping where it already is changes nothing (no undo step).
let n = s.doc().unwrap().history.undo.len();
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n);
// One undo step per drop.
undo(&mut s);
assert_eq!(tree(&s), "G[H[E F] D] B C A");
undo(&mut s);
undo(&mut s);
undo(&mut s);
assert_eq!(tree(&s), "A B C G[D H[E F]]");
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
}
#[test]
fn eye_show_all_and_hide_all() {
let (mut s, ids) = deck();
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
assert!(flags(&s, b).0);
// Hiding a group hides only the group; its children keep their own flags.
s.execute("shape.visible", &json!({"id": g})).unwrap();
assert!(flags(&s, g).0 && !flags(&s, d).0);
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
// Hide All hides the top level only.
s.execute("selection.hideAll", &json!({})).unwrap();
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
// Show All shows everything, children included.
s.execute("selection.showAll", &json!({})).unwrap();
for id in &ids {
assert!(!flags(&s, *id).0, "{id} still hidden");
}
let n = s.doc().unwrap().history.undo.len();
s.execute("selection.showAll", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
undo(&mut s);
assert!(flags(&s, a).0 && flags(&s, e).0);
undo(&mut s);
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
}
#[test]
fn lock_cascades_through_groups_and_undoes() {
let (mut s, ids) = deck();
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
s.execute("shape.lock", &json!({"id": a})).unwrap();
assert!(flags(&s, a).1);
s.execute("shape.lock", &json!({"id": a})).unwrap();
assert!(!flags(&s, a).1, "toggles");
// Locking a group locks everything in it.
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
// A child can be unlocked on its own.
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
assert!(flags(&s, g).1 && !flags(&s, d).1);
// Unlocking the group unlocks everything in it.
s.execute("shape.lock", &json!({"id": g})).unwrap();
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
undo(&mut s);
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
undo(&mut s);
undo(&mut s);
assert!(!flags(&s, g).1 && !flags(&s, e).1);
}
#[test]
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
let (mut s, ids) = deck();
let (a, b) = (ids[0], ids[1]);
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
let before = bx(&mut s, a);
for (cmd, p) in [
("shape.move", json!({"ids": [a], "dx": 10})),
("shape.resize", json!({"ids": [a], "w": 300})),
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
("shape.rotate", json!({"ids": [a], "deg": 45})),
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
("arrange.align", json!({"ids": [a], "edge": "right"})),
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
}
assert_eq!(bx(&mut s, a), before);
// Mixed selections: the unlocked ones move, the locked one stays.
let bb = bx(&mut s, b);
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
assert_eq!(bx(&mut s, a), before);
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
// Formatting and the name still change.
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
// The arrow keys don't nudge it.
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
let _ = s.key("ArrowRight", Mods::default());
assert_eq!(bx(&mut s, a), before);
}
#[test]
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
let (mut s, ids) = deck();
let a = ids[0];
s.prefs.snap_to_grid = false;
s.prefs.smart_guides = false;
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
// A: x 20..120, y 50..130.
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
assert_eq!(bx(&mut s, a)["x"], 20.0);
// No resize handle: pressing on the SE corner and dragging doesn't size it.
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
assert_eq!(bx(&mut s, a)["w"], 100.0);
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
}
#[test]
fn select_from_the_pane() {
let (mut s, ids) = deck();
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
s.execute("selection.select", &json!({"id": b})).unwrap();
assert_eq!(selected(&s), vec![b]);
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![b, c]);
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![c]);
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
assert_eq!(selected(&s), vec![c, a, b]);
// A child inside a group; children across nesting levels go together.
s.execute("selection.select", &json!({"id": d})).unwrap();
assert_eq!(selected(&s), vec![d]);
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![d, e]);
// ...but not with top-level objects: Ctrl+click on one is ignored.
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![d, e]);
s.execute("selection.select", &json!({"id": a})).unwrap();
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
assert_eq!(selected(&s), vec![a]);
// Picking makes no undo step...
let n = s.doc().unwrap().history.undo.len();
s.execute("selection.select", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n);
// ...unless it shows a hidden object again.
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
s.execute("selection.select", &json!({"id": c})).unwrap();
assert_eq!(selected(&s), vec![c]);
assert!(!flags(&s, c).0, "picking a hidden object shows it");
undo(&mut s);
assert!(flags(&s, c).0);
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
}
#[test]
fn rename_from_the_pane_undoes() {
let (mut s, ids) = deck();
let e = ids[4];
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
undo(&mut s);
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
}
+34 -10
View File
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
cmd!(
"shape.lock",
"Lock",
["Selection Pane"],
None,
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
has_selection,
lock
),
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
cmd!(
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
edit_shapes(s, p, "shape.move", |sh| {
edit_geometry(s, p, "shape.move", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
let lock = bool_or(p, "lockAspect", false);
edit_shapes(s, p, "shape.resize", |sh| {
edit_geometry(s, p, "shape.resize", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, p, "shape.setBounds", |sh| {
edit_geometry(s, p, "shape.setBounds", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = f64_param(p, "x").unwrap_or(x.x);
x.y = f64_param(p, "y").unwrap_or(x.y);
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
edit_shapes(s, p, "shape.rotate", |sh| {
edit_geometry(s, p, "shape.rotate", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
x.rot = ((r % 360.0) + 360.0) % 360.0;
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_shapes(s, p, "shape.flip", |sh| {
edit_geometry(s, p, "shape.flip", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_shapes(s, p, "shape.adjust", |sh| {
edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
while adj.len() <= i {
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
fn change(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
edit_shapes(s, p, "shape.change", |sh| {
edit_geometry(s, p, "shape.change", |sh| {
sh.geom = Geom::preset(info.name);
Ok(())
})
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "locked");
edit_shapes(s, p, "shape.lock", |sh| {
sh.locked = v.unwrap_or(!sh.locked);
let to = v.unwrap_or(!sh.locked);
set_locked(sh, to, 0);
Ok(())
})
}
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
sh.locked = to;
if depth > 64 {
return;
}
if let Some(children) = sh.children_mut() {
for c in children {
set_locked(c, to, depth + 1);
}
}
}
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
edit_shapes(s, p, "picture.crop", |sh| {
edit_geometry(s, p, "picture.crop", |sh| {
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
for (i, v) in c.iter().enumerate() {
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
+60 -16
View File
@@ -39,6 +39,16 @@ pub fn specs() -> Vec<CommandSpec> {
commit
),
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
cmd!(
"text.delete",
"Delete Text",
[],
Some("Backspace"),
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
has_text,
delete
),
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
@@ -187,7 +197,9 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point,
&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 })?;
// After End on a wrapped line the caret is drawn at that line's end.
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
}
@@ -232,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
}
s.select(|_, sel| {
sel.shapes = vec![id];
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
})?;
Ok(json!({"id": id, "at": [at.0, at.1]}))
}
@@ -503,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
let dir = dir.to_string();
edit_text(s, move |body, t| {
let (a, b) = t.ordered();
let at = if a != b {
let at = if dir == "wordBackward" || dir == "wordForward" {
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
// (PowerPoint for Windows).
ed::delete_word(body, a, dir == "wordForward")
} else if a != b {
ed::delete(body, a, b)
} else {
let other = match dir.as_str() {
"forward" => ed::char_move(body, a, true),
"wordBackward" => ed::word_move(body, a, false),
"wordForward" => ed::word_move(body, a, true),
_ => {
// Backspace at the start of an indented/bulleted paragraph outdents first.
if a.1 == 0
@@ -536,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
}
/// Is paragraph `para` right-to-left (its lines run from the right)?
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
Some(line) => line.rtl,
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
}
}
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_text::Affinity;
let to = str_param(p, "to").unwrap_or("right").to_string();
let extend = bool_or(p, "extend", false);
let st = s.doc()?;
@@ -545,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let layout = layout_for(st, &t);
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
let caret = t.caret;
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
let to = match (to.as_str(), rtl) {
("left", true) => "right",
("right", true) => "left",
("wordLeft", true) => "wordRight",
("wordRight", true) => "wordLeft",
(x, _) => x,
}
.to_string();
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
let mut upstream = false;
let new = match to.as_str() {
"left" if t.is_range() && !extend => collapse_to(true),
"right" if t.is_range() && !extend => collapse_to(false),
"left" => ed::char_move(&body, caret, false),
"right" => ed::char_move(&body, caret, true),
"wordLeft" => ed::word_move(&body, caret, false),
"wordRight" => ed::word_move(&body, caret, true),
// Ctrl+arrows with a selection go on from its start (left) or end (right).
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
"paraStart" => (caret.0, 0),
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
"paraUp" => ed::para_up(&body, caret),
"paraDown" => ed::para_down(&body, caret),
"start" => (0, 0),
"end" => ed::end(&body),
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
Some(l) => {
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
let r = match to.as_str() {
"up" => l.vertical(lp(caret), false, x),
"down" => l.vertical(lp(caret), true, x),
"lineStart" => l.line_bounds(lp(caret)).0,
_ => l.line_bounds(lp(caret)).1,
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
let (r, a) = match to.as_str() {
"up" => l.vertical_affine(lp(caret), aff, false, x),
"down" => l.vertical_affine(lp(caret), aff, true, x),
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
// End stays on its line: after a soft wrap the caret sits at the line's end.
_ => l.line_end_caret(lp(caret), aff),
};
upstream = a == Affinity::Upstream;
(r.para, r.ch)
}
None => match to.as_str() {
@@ -581,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
s.select(|_, sel| {
if let Some(tt) = sel.text.as_mut() {
tt.caret = new;
tt.line_end = if upstream { Some(new) } else { None };
if !extend {
tt.anchor = new;
}
}
})?;
Ok(json!({"caret": [new.0, new.1]}))
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -633,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
let at = pos_param(p, "at").unwrap_or(t.caret);
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
let body = body_of(st, &t);
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
s.select(|_, sel| {
if let Some(t) = sel.text.as_mut() {
t.anchor = (at.0, 0);
t.caret = (at.0, n);
t.caret = end;
}
})?;
ok()
+79 -11
View File
@@ -20,6 +20,30 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
}
/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for
/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the
/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+].
pub fn key_token(key: &str) -> String {
let k = key.to_ascii_lowercase();
let k = k.trim_start_matches("arrow");
match k {
"period" | ">" => ".",
"comma" | "<" => ",",
"openbracket" | "{" => "[",
"closebracket" | "}" => "]",
"equals" => "=",
"minus" => "-",
"plus" => "+",
"slash" => "/",
"backslash" => "\\",
"semicolon" => ";",
"quote" => "'",
"backtick" => "`",
other => other,
}
.to_string()
}
impl Session {
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
@@ -28,21 +52,17 @@ impl Session {
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();
let k = k.trim_start_matches("arrow");
let k = key_token(key);
let k = k.as_str();
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
return self.finish_freeform(false);
}
if editing && let Some((id, params)) = self.text_key(k, mods) {
let r = self.execute(id, &params)?;
return Ok(json!({"handled": true, "command": id, "result": r}));
}
if editing && !mods.cmd {
let r = match k {
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
"right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))),
"up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))),
"down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))),
"home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))),
"end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))),
"backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))),
"delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))),
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
"tab" => {
@@ -117,6 +137,10 @@ impl Session {
}
// Shortcuts from the registry.
let key_name = match k {
"left" => "Left",
"right" => "Right",
"up" => "Up",
"down" => "Down",
"enter" | "return" => "Enter",
"escape" | "esc" => "Escape",
"delete" => "Delete",
@@ -127,7 +151,7 @@ impl Session {
"end" => "End",
"tab" => "Tab",
"space" | " " => "Space",
_ => key,
_ => k,
};
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
if let Some(id) = found {
@@ -137,6 +161,50 @@ impl Session {
Ok(json!({"handled": false}))
}
/// The text-editing command for a navigation or deletion key while editing text, by
/// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down
/// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and
/// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection.
fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> {
let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt };
let mac_cmd = self.mac_keys && mods.cmd && !mods.alt;
if mods.cmd && !mods.alt && !mods.shift {
// Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <.
match k {
"]" => return Some(("format.grow", json!({}))),
"[" => return Some(("format.shrink", json!({}))),
_ => {}
}
}
let plain = !mods.cmd && !mods.alt;
let to = match k {
"left" if word => "wordLeft",
"right" if word => "wordRight",
"left" if mac_cmd => "lineStart",
"right" if mac_cmd => "lineEnd",
"left" if plain => "left",
"right" if plain => "right",
"up" if mac_cmd => "start",
"down" if mac_cmd => "end",
"up" if word && self.mac_keys => "paraStart",
"down" if word && self.mac_keys => "paraEnd",
"up" if word => "paraUp",
"down" if word => "paraDown",
"up" if plain => "up",
"down" if plain => "down",
"home" if mods.cmd && !mods.alt => "start",
"end" if mods.cmd && !mods.alt => "end",
"home" if plain => "lineStart",
"end" if plain => "lineEnd",
"backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))),
"delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))),
"backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))),
"delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))),
_ => return None,
};
Some(("text.move", json!({"to": to, "extend": mods.shift})))
}
/// Type text: into the text being edited, or (a shape selected) start editing it.
pub fn type_text(&mut self, text: &str) -> Result<Value> {
let st = self.doc()?;
+19
View File
@@ -68,6 +68,10 @@ pub struct TextSel {
/// Editing the slide's speaker notes (shape is ignored).
#[serde(default)]
pub notes: bool,
/// The caret position that sits at the end of a wrapped line (after End) rather than at the
/// start of the next line. Ignored once the caret moves anywhere else.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_end: Option<(usize, usize)>,
}
impl TextSel {
@@ -77,6 +81,10 @@ impl TextSel {
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
}
/// Is the caret drawn at the end of the line before (upstream), not the start of the next?
pub fn at_line_end(&self) -> bool {
self.line_end == Some(self.caret)
}
}
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
@@ -339,6 +347,10 @@ pub struct Session {
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
pub gif_paused: std::collections::HashSet<ShapeId>,
/// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text
/// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the
/// platform DeckCraft was built for.
pub mac_keys: bool,
}
impl Default for Session {
@@ -366,6 +378,7 @@ impl Session {
recovery_dir: None,
media_status: Default::default(),
gif_paused: Default::default(),
mac_keys: cfg!(target_os = "macos"),
}
}
/// A session with one new presentation open.
@@ -492,6 +505,10 @@ impl Session {
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
let st = self.doc_mut()?;
let doc = st.doc.clone();
// A caret placed anywhere starts downstream; only End puts it back at a line's end.
if let Some(t) = st.selection.text.as_mut() {
t.line_end = None;
}
f(&doc, &mut st.selection);
clamp_selection(st);
st.revision += 1;
@@ -536,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_keys;
+404
View File
@@ -0,0 +1,404 @@
//! Text-editing keys driven through `Session::key`, as the keyboard, control channel and MCP
//! send them. Every expectation is PowerPoint for Windows' observed behaviour unless it says Mac.
use serde_json::{Value, json};
use crate::Session;
use crate::tools::Mods;
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
const CTRL: Mods = Mods { shift: false, alt: false, cmd: true };
const CTRL_SHIFT: Mods = Mods { shift: true, alt: false, cmd: true };
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false };
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
type Pos = (usize, usize);
/// A session editing a new text box holding `text` (Windows keys), caret at `at`.
fn editing(text: &str, at: Pos) -> (Session, u32) {
editing_in(text, at, [60.0, 60.0, 600.0, 300.0])
}
fn editing_in(text: &str, at: Pos, rect: [f64; 4]) -> (Session, u32) {
let mut s = Session::with_new();
s.mac_keys = false;
s.prefs.autocorrect = false;
s.prefs.smart_quotes = false;
s.execute("insert.textBox", &json!({"rect": rect, "text": "x"})).unwrap();
let id = s.doc().unwrap().selection.shapes[0].0;
s.execute("text.set", &json!({"id": id, "text": text})).unwrap();
s.execute("text.edit", &json!({"id": id, "at": [at.0, at.1]})).unwrap();
(s, id)
}
fn sel(s: &Session) -> (Pos, Pos) {
let t = s.doc().unwrap().selection.text.clone().unwrap();
(t.anchor, t.caret)
}
fn caret(s: &Session) -> Pos {
sel(s).1
}
fn press(s: &mut Session, key: &str, mods: Mods) -> Value {
let r = s.key(key, mods).unwrap();
assert_eq!(r["handled"], true, "{key} {mods:?} not handled");
r
}
fn text(s: &mut Session, id: u32) -> String {
s.execute("text.get", &json!({"id": id})).unwrap()["text"].as_str().unwrap().to_string()
}
/// Press `key` until the caret stops moving; the carets visited (in the first paragraph).
fn walk(s: &mut Session, key: &str, mods: Mods) -> Vec<usize> {
let mut out = vec![caret(s).1];
for _ in 0..200 {
let before = caret(s);
press(s, key, mods);
if caret(s) == before {
break;
}
out.push(caret(s).1);
}
out
}
#[test]
fn ctrl_arrows_stop_where_powerpoint_does() {
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
let stops = vec![0, 5, 7, 12, 15, 18, 21, 25, 33, 38, 43, 44, 45, 46, 48, 49, 50, 52, 53, 54, 55, 56, 60, 61, 64, 66, 67, 68, 70, 73, 74];
let (mut s, _) = editing(t, (0, 0));
assert_eq!(walk(&mut s, "ArrowRight", CTRL), stops);
let mut back = stops.clone();
back.reverse();
assert_eq!(walk(&mut s, "ArrowLeft", CTRL), back);
}
#[test]
fn ctrl_arrows_over_cjk_rtl_emoji_and_marks() {
let (mut s, _) = editing("日本語のテキスト abc 한국어", (0, 0));
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 15, 16]);
// Arabic and Hebrew words inside a left-to-right paragraph: in logical order.
let (mut s, _) = editing("Hello مرحبا بالعالم world שלום end", (0, 0));
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 6, 12, 20, 26, 31, 34]);
// An emoji ZWJ sequence and a combining mark are never split.
let (mut s, _) = editing("e\u{301}te 👨\u{200d}👩\u{200d}👧 x", (0, 0));
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 5, 11, 12]);
assert_eq!(walk(&mut s, "Left", CTRL), vec![12, 11, 5, 0]);
}
#[test]
fn ctrl_arrows_cross_paragraphs_line_breaks_and_tabs() {
let (mut s, id) = editing("One two\nThree four\n\nFive", (0, 0));
let mut seen = vec![caret(&s)];
for _ in 0..9 {
press(&mut s, "Right", CTRL);
seen.push(caret(&s));
}
assert_eq!(seen, vec![(0, 0), (0, 4), (0, 7), (1, 0), (1, 6), (1, 10), (2, 0), (3, 0), (3, 4), (3, 4)]);
// A line break and a tab are stops of their own.
s.execute("text.set", &json!({"id": id, "text": "x"})).unwrap();
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "Five\u{b}six seven\tTab x"})).unwrap();
s.execute("text.move", &json!({"to": "start"})).unwrap();
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 4, 5, 9, 14, 15, 21, 22]);
}
#[test]
fn ctrl_shift_arrows_extend_by_words() {
let (mut s, _) = editing("Hello world foo, bar\nnext para", (0, 2));
let mut carets = vec![];
for _ in 0..6 {
press(&mut s, "Right", CTRL_SHIFT);
carets.push(sel(&s));
}
let a = (0, 2);
assert_eq!(carets.iter().map(|x| x.1).collect::<Vec<_>>(), vec![(0, 6), (0, 13), (0, 16), (0, 18), (0, 21), (1, 0)]);
assert!(carets.iter().all(|x| x.0 == a));
let mut back = vec![];
for _ in 0..7 {
press(&mut s, "Left", CTRL_SHIFT);
back.push(caret(&s));
}
assert_eq!(back, vec![(0, 21), (0, 18), (0, 16), (0, 13), (0, 6), (0, 0), (0, 0)]);
assert_eq!(sel(&s).0, a);
// From a caret inside a word going left: the word's start, then on.
let (mut s, _) = editing("Hello world foo, bar\nnext para", (1, 2));
press(&mut s, "Left", CTRL_SHIFT);
press(&mut s, "Left", CTRL_SHIFT);
assert_eq!(sel(&s), ((1, 2), (0, 21)));
}
#[test]
fn ctrl_arrows_leave_a_selection_from_its_ends() {
for (anchor, caret_at) in [((0, 2), (0, 7)), ((0, 7), (0, 2))] {
let (mut s, _) = editing("Hello world foo, bar", anchor);
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
press(&mut s, "Right", CTRL);
assert_eq!(sel(&s), ((0, 13), (0, 13)));
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
press(&mut s, "Left", CTRL);
assert_eq!(sel(&s), ((0, 0), (0, 0)));
}
}
#[test]
fn ctrl_backspace_and_delete() {
let (mut s, id) = editing("Hello world foo, bar\nnext para", (0, 13));
press(&mut s, "Backspace", CTRL);
assert_eq!(text(&mut s, id), "Hello foo, bar\nnext para");
assert_eq!(caret(&s), (0, 6));
press(&mut s, "Delete", CTRL);
assert_eq!(text(&mut s, id), "Hello , bar\nnext para".replace(" ,", ","));
assert_eq!(caret(&s), (0, 5));
// At a paragraph's ends they join paragraphs.
s.execute("text.move", &json!({"to": "paraEnd"})).unwrap();
press(&mut s, "Delete", CTRL);
assert_eq!(text(&mut s, id), "Hello, barnext para");
// A selection is not deleted: the word goes from its start (PowerPoint for Windows).
let (mut s, id) = editing("Hello world", (0, 2));
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 7]})).unwrap();
press(&mut s, "Backspace", CTRL);
assert_eq!((text(&mut s, id), sel(&s)), ("llo world".into(), ((0, 0), (0, 0))));
let (mut s, id) = editing("Hello world", (0, 7));
s.execute("text.select", &json!({"anchor": [0, 7], "caret": [0, 2]})).unwrap();
press(&mut s, "Delete", CTRL);
assert_eq!((text(&mut s, id), sel(&s)), ("He world".into(), ((0, 2), (0, 2))));
// It is one undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&mut s, id), "Hello world");
}
#[test]
fn ctrl_up_down_move_by_paragraph() {
let t = "First paragraph that is long enough to wrap in this box.\nSecond\n\nFourth para";
let (mut s, _) = editing_in(t, (0, 5), [60.0, 60.0, 200.0, 300.0]);
let mut seen = vec![];
for _ in 0..5 {
press(&mut s, "Down", CTRL);
seen.push(caret(&s));
}
assert_eq!(seen, vec![(1, 0), (2, 0), (3, 0), (3, 11), (3, 11)]);
s.execute("text.move", &json!({"to": "end"})).unwrap();
press(&mut s, "Left", NONE);
let mut seen = vec![];
for _ in 0..5 {
press(&mut s, "Up", CTRL);
seen.push(caret(&s));
}
assert_eq!(seen, vec![(3, 0), (2, 0), (1, 0), (0, 0), (0, 0)]);
// With Shift they extend; back past the anchor the selection flips.
s.execute("text.edit", &json!({"at": [0, 5]})).unwrap();
press(&mut s, "Down", CTRL_SHIFT);
press(&mut s, "Down", CTRL_SHIFT);
assert_eq!(sel(&s), ((0, 5), (2, 0)));
press(&mut s, "Up", CTRL_SHIFT);
press(&mut s, "Up", CTRL_SHIFT);
assert_eq!(sel(&s), ((0, 5), (0, 0)));
}
#[test]
fn home_end_and_ctrl_home_end() {
let t = "First paragraph that is long enough to wrap across several lines in this narrow box.\nSecond";
let (mut s, _) = editing_in(t, (0, 0), [60.0, 60.0, 160.0, 300.0]);
let st = s.doc().unwrap();
let ts = st.selection.text.clone().unwrap();
let layout = crate::cmd::text::layout_for(st, &ts).unwrap();
let lines: Vec<(usize, usize)> = layout.lines.iter().filter(|l| l.para == 0).map(|l| (l.start, l.end)).collect();
assert!(lines.len() >= 3, "{lines:?}");
let (l1, l2) = (lines[1], lines[2]);
// End goes past a wrapped line's trailing space, to where the next line starts, as in
// PowerPoint, and the caret stays drawn on the line it ended.
let next = lines[2].0;
s.execute("text.edit", &json!({"at": [0, l1.0 + 2]})).unwrap();
press(&mut s, "Home", NONE);
assert_eq!(caret(&s), (0, l1.0));
press(&mut s, "End", NONE);
assert_eq!(caret(&s), (0, next));
assert!(s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
let drawn = |s: &Session| {
let st = s.doc().unwrap();
let ts = st.selection.text.clone().unwrap();
let l = crate::cmd::text::layout_for(st, &ts).unwrap();
let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff).unwrap()
};
let line1_top = layout.lines.iter().filter(|l| l.para == 0).nth(1).unwrap().top;
assert_eq!(drawn(&s).1, line1_top);
// End again stays on the line; Home goes back to its start, not the next line's.
press(&mut s, "End", NONE);
assert_eq!(caret(&s), (0, next));
press(&mut s, "Home", SHIFT);
assert_eq!(sel(&s), ((0, next), (0, l1.0)));
press(&mut s, "End", SHIFT);
assert_eq!(sel(&s), ((0, next), (0, next)));
assert_eq!(drawn(&s).1, line1_top);
// Down from a line's end lands on the next line, not two lines down.
press(&mut s, "Down", NONE);
let c = caret(&s);
assert!(c.1 > l2.0 && c.1 <= l2.1, "{c:?} not on line {l2:?}");
// The caret is drawn at the end of the line it belongs to.
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
let ts = s.doc().unwrap().selection.text.clone().unwrap();
assert!(ts.at_line_end());
// Ctrl+Home / Ctrl+End, with Shift extending.
press(&mut s, "End", CTRL);
assert_eq!(caret(&s), (1, 6));
press(&mut s, "Home", CTRL_SHIFT);
assert_eq!(sel(&s), ((1, 6), (0, 0)));
press(&mut s, "Home", CTRL);
press(&mut s, "End", CTRL_SHIFT);
assert_eq!(sel(&s), ((0, 0), (1, 6)));
// A caret placed anywhere else is drawn at the start of its line again.
s.execute("text.select", &json!({"anchor": [0, next], "caret": [0, next]})).unwrap();
assert!(!s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
assert!(drawn(&s).1 > line1_top);
}
#[test]
fn right_to_left_paragraphs_swap_left_and_right() {
let (mut s, _) = editing("مرحبا بالعالم hello world", (0, 0));
s.execute("format.rtl", &json!({"on": true})).unwrap();
// Ctrl+Left goes forward in a right-to-left paragraph, Ctrl+Right back.
assert_eq!(walk(&mut s, "Left", CTRL), vec![0, 6, 14, 20, 25]);
assert_eq!(walk(&mut s, "Right", CTRL), vec![25, 20, 14, 6, 0]);
press(&mut s, "Left", NONE);
assert_eq!(caret(&s), (0, 1));
press(&mut s, "Left", CTRL_SHIFT);
assert_eq!(sel(&s), ((0, 1), (0, 6)));
}
#[test]
fn mac_keys() {
let (mut s, id) = editing("Hello world foo\nnext", (0, 8));
s.mac_keys = true;
press(&mut s, "Right", ALT);
assert_eq!(caret(&s), (0, 12));
press(&mut s, "Left", ALT);
assert_eq!(caret(&s), (0, 6));
press(&mut s, "Right", CTRL);
assert_eq!(caret(&s), (0, 15));
press(&mut s, "Left", CTRL);
assert_eq!(caret(&s), (0, 0));
press(&mut s, "Down", CTRL);
assert_eq!(caret(&s), (1, 4));
press(&mut s, "Up", CTRL);
assert_eq!(caret(&s), (0, 0));
s.execute("text.edit", &json!({"id": id, "at": [0, 11]})).unwrap();
press(&mut s, "Backspace", ALT);
assert_eq!(text(&mut s, id), "Hello foo\nnext");
// Ctrl+arrows aren't word moves on the Mac, nor Alt+arrows on Windows.
s.mac_keys = false;
let before = caret(&s);
let r = s.key("Right", ALT).unwrap();
assert_eq!((r["handled"].as_bool(), caret(&s)), (Some(false), before));
}
#[test]
fn font_size_keys() {
let (mut s, _) = editing("hello wOrld. foo bar", (0, 0));
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
let size = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["size"].as_f64();
let base = size(&mut s).unwrap_or(18.0);
// egui names the keys `Period`, `Comma`, `CloseBracket`, `OpenBracket`.
press(&mut s, "Period", CTRL_SHIFT);
let up = size(&mut s).unwrap();
assert!(up > base);
press(&mut s, "Comma", CTRL_SHIFT);
assert_eq!(size(&mut s), Some(base));
press(&mut s, "CloseBracket", CTRL);
assert_eq!(size(&mut s), Some(up));
press(&mut s, "OpenBracket", CTRL);
assert_eq!(size(&mut s), Some(base));
press(&mut s, ">", CTRL_SHIFT);
assert_eq!(size(&mut s), Some(up));
}
#[test]
fn bold_italic_underline_and_clear_on_a_caret_in_a_word() {
let (mut s, id) = editing("hello wOrld. foo bar", (0, 8));
let r = press(&mut s, "b", CTRL);
assert_eq!(r["command"], "format.bold");
let body = |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
let runs: Vec<(String, Option<bool>)> = body(&s).paragraphs[0].runs.iter().map(|r| (r.text.clone(), r.props.bold)).collect();
assert_eq!(runs.iter().find(|r| r.1 == Some(true)).map(|r| r.0.as_str()), Some("wOrld"));
// At a word boundary nothing visible changes.
s.execute("text.edit", &json!({"id": id, "at": [0, 5]})).unwrap();
press(&mut s, "i", CTRL);
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.italic != Some(true)));
// Ctrl+Space clears the word's formatting.
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
press(&mut s, "Space", CTRL);
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.bold != Some(true)));
press(&mut s, "u", CTRL);
let under: String = body(&s).paragraphs[0].runs.iter().filter(|r| r.props.underline.is_some()).map(|r| r.text.clone()).collect();
assert_eq!(under, "wOrld");
}
#[test]
fn alignment_keys() {
let (mut s, _) = editing("one\ntwo", (0, 1));
for (k, want) in [("e", "ctr"), ("r", "r"), ("j", "just"), ("l", "l")] {
press(&mut s, k, CTRL);
let st = s.execute("format.state", &json!({})).unwrap();
assert_eq!(st["align"], want, "Ctrl+{k}");
}
}
#[test]
fn shift_f3_cycles_case() {
let (mut s, id) = editing("hello world", (0, 0));
s.execute("text.selectAll", &json!({})).unwrap();
let mut seen = vec![];
for _ in 0..3 {
press(&mut s, "F3", SHIFT);
seen.push(text(&mut s, id));
}
assert_eq!(seen, vec!["Hello World", "HELLO WORLD", "hello world"]);
s.execute("text.set", &json!({"id": id, "text": "hello wOrld. foo"})).unwrap();
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
press(&mut s, "F3", SHIFT);
assert_eq!(text(&mut s, id), "hello WORLD. foo");
assert_eq!(sel(&s), ((0, 6), (0, 6)));
press(&mut s, "F3", SHIFT);
assert_eq!(text(&mut s, id), "hello world. foo");
}
#[test]
fn shift_enter_and_word_and_paragraph_selection() {
let (mut s, id) = editing("Hello world, foo bar\nnext", (0, 7));
s.execute("text.selectWord", &json!({})).unwrap();
assert_eq!(sel(&s), ((0, 6), (0, 11)));
s.execute("text.selectWord", &json!({"at": [0, 12]})).unwrap();
assert_eq!(sel(&s), ((0, 11), (0, 12)));
// Triple-click: the paragraph and its mark.
s.execute("text.selectParagraph", &json!({"at": [0, 3]})).unwrap();
assert_eq!(sel(&s), ((0, 0), (1, 0)));
s.execute("text.selectParagraph", &json!({"at": [1, 1]})).unwrap();
assert_eq!(sel(&s), ((1, 0), (1, 4)));
s.execute("text.edit", &json!({"id": id, "at": [1, 2]})).unwrap();
press(&mut s, "Enter", SHIFT);
let b = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
assert_eq!(b.paragraphs.len(), 2);
assert_eq!(b.paragraphs[1].text(), "ne\u{b}xt");
}
#[test]
fn keys_never_panic_on_any_text() {
let texts = ["", " ", "\u{b}", "\t\t", "👨\u{200d}👩\u{200d}👧", "e\u{301}", "日本", "مرحبا", "a\n\nb", " x \n "];
let keys = ["Left", "Right", "Up", "Down", "Home", "End", "Backspace", "Delete"];
for t in texts {
for k in keys {
for m in [CTRL, CTRL_SHIFT, NONE, SHIFT, ALT] {
for at in [(0, 0), (0, 1), (5, 5)] {
let (mut s, _) = editing(t, at);
for _ in 0..3 {
let _ = s.key(k, m).unwrap();
}
}
}
}
}
}
+12 -4
View File
@@ -219,6 +219,10 @@ 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();
@@ -247,8 +251,9 @@ 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 || sh.locked {
if sh.hidden {
continue;
}
if let Some(h) = hit_shape(st, sh, p, tol) {
@@ -423,7 +428,9 @@ impl Session {
} else {
vec![id]
};
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
// 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();
// 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));
@@ -433,7 +440,8 @@ impl Session {
sel.text = None;
sel.cells = None;
})?;
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
let duplicate = mods.alt && origs.len() == ids.len();
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
// Format Painter applies on click.
if self.painter.is_some() && !toggle {
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
@@ -464,7 +472,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) {
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
return Ok(Value::Null);
}
if !moved {
+1
View File
@@ -14,3 +14,4 @@ deckcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
unicode-segmentation = { workspace = true }
+248 -45
View File
@@ -2,6 +2,7 @@
//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
use crate::words;
/// (paragraph, char offset in the paragraph).
pub type Pos = (usize, usize);
@@ -275,65 +276,109 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
caret
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\''
fn para_text(body: &TextBody, pi: usize) -> String {
body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default()
}
/// Word boundaries around `pos` (double-click selection).
/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its
/// trailing spaces, as PowerPoint selects it.
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
let (pi, ch) = clamp(body, pos);
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
if t.is_empty() {
return ((pi, 0), (pi, 0));
}
let at = ch.min(t.len().saturating_sub(1));
let w = t.get(at).copied().is_some_and(is_word);
let mut s = at;
while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) {
s -= 1;
}
let mut e = at;
while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) {
e += 1;
}
// Include trailing spaces after a word.
while w && e < t.len() && t.get(e) == Some(&' ') {
e += 1;
}
((pi, s), (pi, e.max(s)))
let (s, e) = words::word_at(&para_text(body, pi), ch);
((pi, s), (pi, e))
}
/// Next/previous word start (Option/Ctrl + arrows).
/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of
/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the
/// end of the previous one.
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
let (pi, ch) = clamp(body, pos);
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
let t = para_text(body, pi);
if forward {
if ch >= t.len() {
return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) };
match words::next_stop(&t, ch) {
Some(s) => (pi, s),
None if pi + 1 < body.paragraphs.len() => (pi + 1, 0),
None => (pi, ch),
}
let mut i = ch;
while i < t.len() && t.get(i).copied().is_some_and(is_word) {
i += 1;
}
while i < t.len() && !t.get(i).copied().is_some_and(is_word) {
i += 1;
}
(pi, i)
} else {
if ch == 0 {
return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) };
match words::prev_stop(&t, ch) {
Some(s) => (pi, s),
None if pi > 0 => (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)),
None => (pi, 0),
}
let mut i = ch;
while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) {
i -= 1;
}
while i > 0 && t.get(i - 1).copied().is_some_and(is_word) {
i -= 1;
}
(pi, i)
}
}
/// Ctrl+Up: the start of the paragraph, or of the previous one when already there.
pub fn para_up(body: &TextBody, pos: Pos) -> Pos {
let (pi, ch) = clamp(body, pos);
if ch > 0 { (pi, 0) } else { (pi.saturating_sub(1), 0) }
}
/// Ctrl+Down: the start of the next paragraph, or the end of the last one.
pub fn para_down(body: &TextBody, pos: Pos) -> Pos {
let (pi, _) = clamp(body, pos);
if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) }
}
/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in
/// PowerPoint; returns the caret.
///
/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the
/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a
/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then
/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or
/// at the end of the paragraph after a word go, and sentence punctuation left against a word
/// gets a space. At a paragraph's end it joins the next paragraph.
pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos {
if body.paragraphs.is_empty() {
return (0, 0);
}
let (pi, ch) = clamp(body, pos);
let t = para_text(body, pi);
if !forward {
let to = match words::prev_stop(&t, ch) {
Some(s) => (pi, s),
None => char_move(body, (pi, ch), false),
};
return delete(body, to, (pi, ch));
}
let Some(e) = words::delete_forward_end(&t, ch) else {
let to = char_move(body, (pi, ch), true);
return delete(body, (pi, ch), to);
};
let at = delete(body, (pi, ch), (pi, e));
smart_space(body, at)
}
/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret.
fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
let (pi, s) = at;
let chars: Vec<char> = para_text(body, pi).chars().collect();
let after = chars.get(s).copied();
let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied();
if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') {
let mut w = s;
while w > 0 && chars.get(w - 1) == Some(&' ') {
w -= 1;
}
if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) {
return delete(body, (pi, w), (pi, s));
}
return at;
}
if let (Some(b), Some(a)) = (before, after)
&& words::is_sentence_punct(b)
&& a.is_alphanumeric()
{
let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric();
if !digits {
return insert(body, at, " ", None);
}
}
at
}
/// One character left/right across paragraph boundaries.
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
let (pi, ch) = clamp(body, pos);
@@ -361,6 +406,18 @@ pub fn end(body: &TextBody) -> Pos {
(pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0))
}
/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each
/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first.
pub fn next_case(s: &str) -> &'static str {
if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) {
"title"
} else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) {
"lower"
} else {
"upper"
}
}
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
pub fn change_case(s: &str, mode: &str) -> String {
match mode {
@@ -456,7 +513,7 @@ mod tests {
#[test]
fn words_and_moves() {
let b = body("Hello brave world");
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 12)));
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11)));
assert_eq!(word_move(&b, (0, 0), true), (0, 6));
assert_eq!(word_move(&b, (0, 8), false), (0, 6));
assert_eq!(char_move(&b, (0, 17), true), (0, 17));
@@ -472,4 +529,150 @@ mod tests {
assert_eq!(b.paragraphs[0].char_len(), 3);
assert_eq!(b.paragraphs.len(), 1);
}
/// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results).
fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) {
let mut b = body(text);
let c = delete_word(&mut b, at, forward);
(b.text(), c)
}
#[test]
fn ctrl_backspace_like_powerpoint() {
let t = "ab cd...ef gh";
let want = [
("ab cd...ef gh", 0),
("b cd...ef gh", 0),
(" cd...ef gh", 0),
(" cd...ef gh", 0),
(" cd...ef gh", 0),
("cd...ef gh", 0),
("ab d...ef gh", 5),
("ab ...ef gh", 5),
("ab cd..ef gh", 7),
("ab cd.ef gh", 7),
("ab cdef gh", 7),
("ab cd...f gh", 10),
("ab cd... gh", 10),
("ab cd...gh", 10),
("ab cd...ef h", 13),
("ab cd...ef ", 13),
];
for (c, (txt, caret)) in want.iter().enumerate() {
assert_eq!(del_word(t, (0, c), false), (txt.to_string(), (0, *caret)), "caret {c}");
}
let w = "Hello world foo, bar\nnext para";
assert_eq!(del_word(w, (0, 12), false), ("Hello foo, bar\nnext para".into(), (0, 6)));
assert_eq!(del_word(w, (0, 17), false), ("Hello world foo bar\nnext para".into(), (0, 16)));
// At a paragraph's start it joins the paragraph before.
assert_eq!(del_word(w, (1, 0), false), ("Hello world foo, barnext para".into(), (0, 21)));
assert_eq!(del_word("ab\n cd", (1, 2), false), ("ab\ncd".into(), (1, 0)));
// A line break is a word of its own.
let mut b = body("ab");
insert(&mut b, (0, 2), "\u{b}cd", None);
assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2));
assert_eq!(b.text(), "abcd");
let mut b = body("ab");
insert(&mut b, (0, 2), "\u{b}cd", None);
assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2));
assert_eq!(b.text(), "abcd");
assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0)));
// Emoji sequences and combining marks are never split.
assert_eq!(del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false), ("a ".into(), (0, 2)));
assert_eq!(del_word("ce\u{301}te x", (0, 5), false), (" x".into(), (0, 0)));
assert_eq!(del_word("ce\u{301}te x", (0, 6), false), ("x".into(), (0, 0)));
// Each CJK character is a word.
assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2)));
}
#[test]
fn ctrl_delete_like_powerpoint() {
let t = "ab cd...ef gh";
let want = [
("cd...ef gh", 0),
("a cd...ef gh", 1),
("ab...ef gh", 2),
("ab cd...ef gh", 3),
("ab cd...ef gh", 4),
("ab...ef gh", 2),
("ab c...ef gh", 6),
("ab cdef gh", 7),
("ab cd. ef gh", 9),
("ab cd.. ef gh", 10),
("ab cd... gh", 11),
("ab cd...e gh", 11),
("ab cd...ef", 12),
("ab cd...ef", 12),
("ab cd...ef g", 14),
("ab cd...ef gh", 15),
];
for (c, (txt, caret)) in want.iter().enumerate() {
assert_eq!(del_word(t, (0, c), true), (txt.to_string(), (0, *caret)), "caret {c}");
}
let w = "Hello world foo, bar\nnext para";
let want = [
(0, "world foo, bar\nnext para", 0),
(2, "He world foo, bar\nnext para", 2),
(5, "Hello foo, bar\nnext para", 5),
(6, "Hello foo, bar\nnext para", 6),
(8, "Hello wo foo, bar\nnext para", 8),
(11, "Hello world, bar\nnext para", 11),
(12, "Hello world foo, bar\nnext para", 12),
(16, "Hello world foobar\nnext para", 16),
(17, "Hello world foo,\nnext para", 17),
(18, "Hello world foo, \nnext para", 18),
(21, "Hello world foo, barnext para", 21),
];
for (c, txt, caret) in want {
assert_eq!(del_word(w, (0, c), true), (txt.to_string(), (0, caret)), "caret {c}");
}
assert_eq!(del_word(w, (1, 0), true), ("Hello world foo, bar\npara".into(), (1, 0)));
assert_eq!(del_word(w, (1, 2), true), ("Hello world foo, bar\nne para".into(), (1, 2)));
assert_eq!(del_word("ab \ncd", (0, 2), true), ("ab\ncd".into(), (0, 2)));
assert_eq!(del_word("ab \ncd", (0, 3), true), ("ab\ncd".into(), (0, 2)));
assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2)));
assert_eq!(del_word("ab cd(x) ef", (0, 3), true), ("ab (x) ef".into(), (0, 3)));
assert_eq!(del_word("ab cd, ef", (0, 3), true), ("ab, ef".into(), (0, 2)));
assert_eq!(del_word("ab. (cd) ef", (0, 5), true), ("ab. () ef".into(), (0, 5)));
assert_eq!(del_word("ab \"cd\" ef", (0, 4), true), ("ab \"\" ef".into(), (0, 4)));
assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4)));
// Right-to-left text deletes in logical order like any other.
assert_eq!(del_word("مرحبا بالعالم hello", (0, 6), true), ("مرحبا hello".into(), (0, 6)));
assert_eq!(del_word("مرحبا بالعالم hello", (0, 8), false), ("مرحبا لعالم hello".into(), (0, 6)));
// Numbers keep their separators: no space is pushed into "3.14".
assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
// Hostile positions clamp.
assert_eq!(del_word("ab", (9, 9), true).0, "ab");
assert_eq!(del_word("", (0, 0), false).0, "");
let mut empty = TextBody::default();
assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0));
}
#[test]
fn paragraph_moves() {
let b = body("one two\nthree\n\nfour");
assert_eq!(para_down(&b, (0, 2)), (1, 0));
assert_eq!(para_down(&b, (1, 0)), (2, 0));
assert_eq!(para_down(&b, (3, 1)), (3, 4));
assert_eq!(para_up(&b, (3, 2)), (3, 0));
assert_eq!(para_up(&b, (3, 0)), (2, 0));
assert_eq!(para_up(&b, (0, 0)), (0, 0));
assert_eq!(word_move(&b, (0, 4), true), (0, 7));
assert_eq!(word_move(&b, (0, 7), true), (1, 0));
assert_eq!(word_move(&b, (1, 0), false), (0, 7));
assert_eq!(word_move(&b, (2, 0), true), (3, 0));
assert_eq!(word_move(&b, (3, 4), true), (3, 4));
assert_eq!(word_move(&b, (0, 0), false), (0, 0));
}
#[test]
fn case_cycle() {
assert_eq!(next_case("hello world"), "title");
assert_eq!(next_case("Hello World"), "upper");
assert_eq!(next_case("HELLO WORLD"), "lower");
assert_eq!(next_case("Hello world"), "upper");
assert_eq!(next_case("hELLO wORLD"), "upper");
assert_eq!(next_case("hello. world is HERE"), "upper");
assert_eq!(next_case("123"), "upper");
}
}
+41
View File
@@ -16,6 +16,7 @@ pub mod style;
pub mod table;
pub mod text;
pub mod theme;
pub mod words;
use std::sync::Arc;
@@ -443,6 +444,46 @@ 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")]
+20
View File
@@ -210,3 +210,23 @@ 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());
}
+420
View File
@@ -0,0 +1,420 @@
//! Word boundaries in a paragraph, as PowerPoint's Ctrl+arrow, Ctrl+Backspace/Delete and
//! double-click see them (observed black-box on PowerPoint for Windows, see
//! `docs/ui-parity.md`, Text editing keys).
//!
//! A paragraph splits into *tokens*. Each token is a run of one class of grapheme clusters plus
//! the spaces that follow it:
//!
//! - **word**: letters, digits and combining marks. An apostrophe (`'` or `’`) joins a word when a
//! letter or digit follows it (`it's`, `'tis`, but `dogs'` ends before it); a hyphen joins when
//! a letter or digit comes before it (`a-b`, `1-2`, `a-`); `.`, `,` and `:` join between digits
//! (`3.14`, `1,000`, `3:45`, but `e.g.` is five tokens).
//! - **punctuation**: everything else that is not a symbol; a run of it is one token (`...`,
//! `?!`, `),`, `。。`). `_`, `@`, `/`, `#`, quotes and brackets are punctuation.
//! - **symbol**: emoji and pictographs (a run of them is one token, `👍👍`), `©`, `®`, `™`.
//! - **ideograph**: each CJK ideograph, kana or Hangul syllable is a token of its own.
//! - **tab** and **line break**: each is a token of its own, and spaces after one form their own
//! token instead of joining it.
//! - **space**: U+0020, no-break space, ideographic space and the other fixed-width spaces. Spaces
//! join the token before them; spaces at the start of a paragraph (or after a tab or line break)
//! are a token of their own.
//!
//! Positions are character (Unicode scalar) offsets in the paragraph text, where a line break is
//! one `\u{b}` character. A token never splits a grapheme cluster, so the caret never lands
//! inside an emoji sequence or between a letter and its combining marks.
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Class {
Space,
Tab,
Break,
Word,
Ideo,
Punct,
Symbol,
}
/// One grapheme cluster: its first character offset and class.
#[derive(Clone, Copy, Debug)]
struct Cluster {
start: usize,
class: Class,
}
/// A space for word movement: absorbed into the token before it.
pub fn is_space(c: char) -> bool {
matches!(c, ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}')
}
/// CJK ideographs, kana and Hangul: every one is a word of its own.
fn is_ideo(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{11ff}'
| '\u{2e80}'..='\u{2fdf}'
| '\u{3005}'..='\u{3007}'
| '\u{3021}'..='\u{3029}'
| '\u{3031}'..='\u{3035}'
| '\u{3038}'..='\u{303c}'
| '\u{3040}'..='\u{309f}'
| '\u{30a0}'..='\u{30ff}'
| '\u{3100}'..='\u{318f}'
| '\u{31a0}'..='\u{31ff}'
| '\u{3400}'..='\u{4dbf}'
| '\u{4e00}'..='\u{9fff}'
| '\u{a960}'..='\u{a97f}'
| '\u{ac00}'..='\u{d7ff}'
| '\u{f900}'..='\u{faff}'
| '\u{ff66}'..='\u{ffdc}'
| '\u{20000}'..='\u{3134f}')
}
/// Emoji, pictographs and the common letter-like symbols.
fn is_symbol(c: char) -> bool {
matches!(c,
'\u{a9}' | '\u{ae}' | '\u{2122}'
| '\u{2190}'..='\u{21ff}'
| '\u{2300}'..='\u{23ff}'
| '\u{25a0}'..='\u{27bf}'
| '\u{2b00}'..='\u{2bff}'
| '\u{1f000}'..='\u{1faff}')
}
fn base_class(c: char) -> Class {
match c {
'\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
'\t' => Class::Tab,
c if is_space(c) => Class::Space,
c if is_ideo(c) => Class::Ideo,
c if c.is_alphanumeric() => Class::Word,
c if is_symbol(c) => Class::Symbol,
// A combining mark with no base (or any other mark) stays with letters.
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word,
_ => Class::Punct,
}
}
fn clusters(text: &str) -> Vec<Cluster> {
let mut out: Vec<Cluster> = Vec::new();
let mut firsts: Vec<char> = Vec::new();
let mut pos = 0;
for g in text.graphemes(true) {
let n = g.chars().count();
let first = g.chars().next().unwrap_or(' ');
// "\r\n" is one grapheme; any other cluster takes the class of its first character.
out.push(Cluster { start: pos, class: base_class(first) });
firsts.push(first);
pos += n;
}
// Context: apostrophes, hyphens and number separators that belong to a word.
let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
for (i, cl) in out.iter_mut().enumerate() {
let Some(&c) = firsts.get(i) else { continue };
let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
let next = firsts.get(i + 1).copied();
let joins = match c {
'\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
'-' => prev.is_some_and(char::is_alphanumeric),
'.' | ',' | ':' => prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric),
_ => false,
};
if joins && classes.get(i) == Some(&Class::Punct) {
cl.class = Class::Word;
}
}
out
}
/// Does a token start at cluster `cur` (after `prev`)?
fn starts_token(prev: Class, cur: Class) -> bool {
if cur == Class::Space {
// Spaces join the token before them, except after a tab or a line break.
return matches!(prev, Class::Tab | Class::Break);
}
prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
}
/// The word stops of a paragraph's text in ascending order: 0, the start of every token, and the
/// end of the text. Ctrl+Right goes to the next one.
pub fn stops(text: &str) -> Vec<usize> {
let cs = clusters(text);
let mut out = vec![0];
for w in cs.windows(2) {
if let [a, b] = w
&& starts_token(a.class, b.class)
{
out.push(b.start);
}
}
let n = text.chars().count();
if out.last() != Some(&n) {
out.push(n);
}
out
}
/// The stops Ctrl+Left uses. PowerPoint's backward scan differs from the forward one in two
/// observed details: a no-break or other non-ASCII space is a stop of its own (unless an ASCII
/// space follows it), and a symbol followed by punctuation (`👍?`) is one token.
pub fn back_stops(text: &str) -> Vec<usize> {
let cs = clusters(text);
let chars: Vec<char> = text.chars().collect();
let mut out = vec![0];
for w in cs.windows(2) {
if let [a, b] = w {
let symbol_punct = matches!((a.class, b.class), (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol));
if starts_token(a.class, b.class) && !symbol_punct {
out.push(b.start);
}
}
}
for (i, c) in chars.iter().enumerate() {
if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
out.push(i);
}
}
out.sort_unstable();
out.dedup();
let n = chars.len();
if out.last() != Some(&n) {
out.push(n);
}
out
}
/// The next stop after `ch` (Ctrl+Right), or `None` at the end of the text.
pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
stops(text).into_iter().find(|s| *s > ch)
}
/// The previous stop before `ch` (Ctrl+Left), or `None` at the start.
pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
back_stops(text).into_iter().rev().find(|s| *s < ch)
}
/// Is the character at `i` a space (for word purposes)?
fn space_at(chars: &[char], i: usize) -> bool {
chars.get(i).is_some_and(|c| is_space(*c))
}
/// The end of the token's text (before its trailing spaces) for the token containing `ch`.
fn content_end(text: &str, ch: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let next = next_stop(text, ch).unwrap_or(chars.len());
let mut e = ch;
while e < next && !space_at(&chars, e) {
e += 1;
}
// A token made only of spaces (leading spaces) ends at the next stop.
if e == ch && space_at(&chars, ch) { next } else { e }
}
/// The word a double-click at `ch` selects: the token under the caret without its trailing
/// spaces; on a space just after a token, that token; in a run of spaces, the spaces.
pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
if n == 0 {
return (0, 0);
}
let ch = ch.min(n);
let on_space = ch >= n || space_at(&chars, ch);
let after_token = ch > 0 && !space_at(&chars, ch - 1);
let at = if on_space && after_token { ch - 1 } else { ch.min(n - 1) };
if space_at(&chars, at) {
let mut s = at;
while s > 0 && space_at(&chars, s - 1) {
s -= 1;
}
let mut e = at;
while e < n && space_at(&chars, e) {
e += 1;
}
return (s, e);
}
let s = stops(text).into_iter().rev().find(|s| *s <= at).unwrap_or(0);
(s, content_end(text, s).max(at + 1).min(n))
}
/// What Ctrl+Delete removes from `ch` (before smart spacing): inside a token, the rest of its
/// text; on spaces right after a token, those spaces and the next token's text; anywhere else up
/// to the next stop. `None` at the end of the text.
pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
if ch >= n {
return None;
}
let st = stops(text);
let at_stop = st.contains(&ch);
if !at_stop && !space_at(&chars, ch) {
return Some(content_end(text, ch));
}
let prev_is_text = ch > 0 && chars.get(ch - 1).is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
if space_at(&chars, ch) && prev_is_text {
let mut j = ch;
while j < n && space_at(&chars, j) {
j += 1;
}
if j >= n {
return Some(n);
}
return Some(content_end(text, j));
}
st.into_iter().find(|s| *s > ch)
}
/// Closing punctuation that a smart delete pulls its word's spaces up to (`ab cd, ef` → Ctrl+Delete
/// on `cd` gives `ab, ef`).
pub fn is_closing_punct(c: char) -> bool {
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '%' | '\u{2026}' | '\u{201d}' | '\u{2019}' | '\u{bb}')
}
/// Punctuation that a smart delete keeps a space after when a word follows (`cd...ef gh` →
/// Ctrl+Delete on `ef` gives `cd... gh`).
pub fn is_sentence_punct(c: char) -> bool {
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
}
#[cfg(test)]
mod tests {
use super::*;
/// Stops drawn as `|` in the text, for readable assertions.
fn marked(text: &str, st: &[usize]) -> String {
let mut out = String::new();
for (i, c) in text.chars().enumerate() {
if st.contains(&i) {
out.push('|');
}
out.push(match c {
'\u{b}' => '↵',
'\t' => '→',
c => c,
});
}
if st.contains(&text.chars().count()) {
out.push('|');
}
out
}
fn fwd(text: &str) -> String {
marked(text, &stops(text))
}
fn back(text: &str) -> String {
marked(text, &back_stops(text))
}
// Every expected string below is a stop trace observed in PowerPoint for Windows.
#[test]
fn punctuation_and_spaces() {
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
assert_eq!(fwd(t), want);
assert_eq!(back(t), want);
}
#[test]
fn hyphens_apostrophes_numbers() {
let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end";
assert_eq!(
fwd(t),
"|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|"
);
let t = "it’s x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 —x x— a's 'tis o'clock ‘q’ end";
assert_eq!(fwd(t), "|it’s |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |—|x |x|— |a's |'tis |o'clock |‘|q|’ |end|");
}
#[test]
fn marks_emoji_symbols() {
let t = "café e\u{301}te 👍🏽 x 👨\u{200d}👩\u{200d}👧 y ½ ®z «q» “w” ¿no? ok—em dash–en x";
let want = "|café |e\u{301}te |👍🏽 |x |👨\u{200d}👩\u{200d}👧 |y |½ |®|z |«|q|» |“|w|” |¿|no|? |ok|—|em |dash|–|en |x|";
assert_eq!(fwd(t), want);
assert_eq!(back(t), want);
// A run of emoji is one token; emoji then punctuation is two going right, one going left.
assert_eq!(fwd("👍👍 x 👍? x👍y"), "|👍👍 |x |👍|? |x|👍|y|");
assert_eq!(back("👍👍 x 👍? x👍y"), "|👍👍 |x |👍? |x|👍|y|");
}
#[test]
fn cjk_each_character() {
let t = "日本語のテキストです。カタカナ漢字 abc 中文字符,标点。한국어 문장 a。。b";
assert_eq!(fwd(t), "|日|本|語|の|テ|キ|ス|ト|で|す|。|カ|タ|カ|ナ|漢|字 |abc |中|文|字|符|,|标|点|。|한|국|어 |문|장 |a|。。|b|");
assert_eq!(back(t), fwd(t));
}
#[test]
fn rtl_words_in_logical_order() {
let t = "Hello مرحبا بالعالم world שלום עולם end";
assert_eq!(fwd(t), "|Hello |مرحبا |بالعالم |world |שלום |עולם |end|");
}
#[test]
fn tabs_breaks_and_other_spaces() {
assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |");
assert_eq!(fwd("Five\u{b}six seven\tTab x"), "|Five|↵|six |seven|→|Tab |x|");
assert_eq!(fwd("a\t b x \u{b} y q\u{b}\u{b}z"), "|a|→| |b |x |↵| |y |q|↵|↵|z|");
let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end";
assert_eq!(fwd(t), "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
assert_eq!(back(t), "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
}
#[test]
fn next_and_prev() {
let t = " lead x mid ";
assert_eq!(next_stop(t, 1), Some(2));
assert_eq!(next_stop(t, 9), Some(11));
assert_eq!(next_stop(t, 16), None);
assert_eq!(prev_stop(t, 1), Some(0));
assert_eq!(prev_stop(t, 9), Some(7));
assert_eq!(prev_stop(t, 15), Some(11));
assert_eq!(prev_stop(t, 0), None);
assert_eq!(next_stop("", 0), None);
assert_eq!(prev_stop("", 0), None);
}
#[test]
fn double_click_word() {
let t = "Hello world, foo bar";
assert_eq!(word_at(t, 7), (6, 11));
assert_eq!(word_at(t, 15), (14, 17));
assert_eq!(word_at(t, 11), (11, 12));
assert_eq!(word_at(t, 12), (11, 12));
assert_eq!(word_at(t, 13), (12, 14));
assert_eq!(word_at(t, 21), (18, 21));
assert_eq!(word_at("日本語のテキスト abc", 5), (5, 6));
assert_eq!(word_at("", 3), (0, 0));
assert_eq!(word_at("x👨\u{200d}👩\u{200d}👧y", 2), (1, 6));
}
#[test]
fn delete_forward_extent() {
let t = "ab cd...ef gh";
let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
// Before smart spacing: PowerPoint's results after it are tested in `edit`.
assert_eq!(
ends,
vec![
Some(5),
Some(2),
Some(7),
Some(5),
Some(5),
Some(7),
Some(7),
Some(10),
Some(10),
Some(10),
Some(13),
Some(12),
Some(15),
Some(15),
Some(15),
None
]
);
assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
assert_eq!(delete_forward_end("ab ", 2), Some(4));
}
}
+14 -2
View File
@@ -104,6 +104,18 @@ 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"));
@@ -122,10 +134,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(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
s.locked = e.child("spLocks").is_some_and(is_locked);
}
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked);
}
"nvPr" => {
if let Some(ph) = e.child("ph") {
+18 -2
View File
@@ -177,8 +177,24 @@ 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()).t("noMove", s.locked).t("noResize", s.locked);
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 }
}
};
if a.v.is_empty() {
return;
}
@@ -458,7 +474,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("noMove", s.locked).t("noResize", s.locked));
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked));
w.close("p:cNvGraphicFramePr");
nv_pr(w, s);
w.close("p:nvGraphicFramePr");
+211
View File
@@ -0,0 +1,211 @@
//! 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);
}
+52 -6
View File
@@ -1860,24 +1860,70 @@ impl TextLayout {
}
/// 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 };
let target = if down { li + 1 } else { li.wrapping_sub(1) };
let Some(l) = self.lines.get(target) else { return pos };
self.vertical_affine(pos, Affinity::Downstream, down, x).0
}
/// The line a caret is on. An upstream caret at the start of a soft-wrapped line (where End
/// leaves it, after the previous line's trailing spaces) belongs to the line before.
pub fn line_index(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
if affinity == Affinity::Upstream && pos.ch > 0 {
for (i, w) in self.lines.windows(2).enumerate() {
if let [a, b] = w
&& b.para == pos.para
&& a.para == pos.para
&& b.start == pos.ch
{
return Some(i);
}
}
}
self.line_of_affine(pos, affinity)
}
/// Up/Down from a caret with its affinity: the position on the line above/below nearest `x`.
pub fn vertical_affine(&self, pos: Pos, affinity: Affinity, down: bool, x: f64) -> (Pos, Affinity) {
let Some(li) = self.line_index(pos, affinity) else { return (pos, affinity) };
let target = if down { li.checked_add(1) } else { li.checked_sub(1) };
let Some(l) = target.and_then(|t| self.lines.get(t)) else { return (pos, affinity) };
let p = Point::new(x, (l.top + l.bottom) / 2.0);
let mut h = self.hit(p);
if h.para != l.para {
h.para = l.para;
h.ch = h.ch.clamp(l.start, l.end);
}
h
h.ch = h.ch.clamp(l.start, l.end);
(h, Affinity::Downstream)
}
/// Start/end of the visual line containing `pos` (logical: Home/End).
pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
match self.line_of(pos).and_then(|i| self.lines.get(i)) {
self.line_bounds_affine(pos, Affinity::Downstream)
}
/// Start/end of the visual line a caret with this affinity is on.
pub fn line_bounds_affine(&self, pos: Pos, affinity: Affinity) -> (Pos, Pos) {
match self.line_index(pos, affinity).and_then(|i| self.lines.get(i)) {
Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }),
None => (pos, pos),
}
}
/// Where End puts the caret: after a soft wrap, past the line's trailing spaces at the start
/// of the next line with upstream affinity (so it is still drawn on this line), else the end
/// of the paragraph.
pub fn line_end_caret(&self, pos: Pos, affinity: Affinity) -> (Pos, Affinity) {
let Some(li) = self.line_index(pos, affinity) else { return (pos, Affinity::Downstream) };
let Some(l) = self.lines.get(li) else { return (pos, Affinity::Downstream) };
match self.lines.get(li + 1) {
Some(n) if n.para == l.para && n.start > l.start => (Pos { para: l.para, ch: n.start }, Affinity::Upstream),
_ => (Pos { para: l.para, ch: l.end }, Affinity::Downstream),
}
}
/// The caret to draw (x, top, bottom): an upstream caret after a soft wrap is drawn at the end
/// of the line it belongs to.
pub fn caret_drawn(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
let li = self.line_index(pos, affinity)?;
let l = self.lines.get(li)?;
if pos.ch > l.end {
let x = l.caret_x.last().copied()?;
return Some((x, l.top, l.bottom));
}
self.caret_at(pos, affinity)
}
/// Bounding box of all glyphs/lines (shape-local).
pub fn bounds(&self) -> Rect {
let mut r: Option<Rect> = None;
+12
View File
@@ -461,6 +461,13 @@ 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);
@@ -640,6 +647,11 @@ 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 -7
View File
@@ -284,6 +284,8 @@ 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",
@@ -2262,22 +2264,30 @@ 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, Y, K);
p.dot((12.0, 15.0), 1.5, D);
p.seg((12.0, 15.5), (12.0, 18.3), D);
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);
}
I::Eye | I::EyeOff => {
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));
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));
e.pop();
p.shape(&e, 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 });
p.circle((12.0, 12.0), 3.2, N, K);
if icon == I::EyeOff {
p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1);
}
+87
View File
@@ -16,6 +16,7 @@ pub mod media;
pub mod menus;
pub mod panes;
pub mod ribbon;
pub mod selpane;
pub mod show;
pub mod sorter;
pub mod status;
@@ -219,6 +220,8 @@ pub struct SlideApp {
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 {
@@ -258,6 +261,7 @@ impl SlideApp {
notes_buf: (0, usize::MAX, String::new()),
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
}
}
@@ -1032,6 +1036,89 @@ mod tests {
assert_eq!(harness.state().session.documents().len(), 2);
}
/// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real
/// egui key events (Windows keys).
#[test]
fn ctrl_word_keys_edit_text() {
use egui::{Key, Modifiers};
let mut session = Session::with_new();
session.mac_keys = false;
session.prefs.autocorrect = false;
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, Services::default()),
);
harness.run_steps(2);
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
harness.event(egui::Event::Text("Hello, world. Foo bar".into()));
harness.run_steps(1);
assert_eq!(title_text(harness.state()), "Hello, world. Foo bar");
let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap();
let ctrl = Modifiers::COMMAND;
let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT;
// Ctrl+Left stops at every word and punctuation run, as in PowerPoint.
let mut stops = vec![];
for _ in 0..6 {
harness.key_press_modifiers(ctrl, Key::ArrowLeft);
harness.run_steps(1);
stops.push(caret(&harness).caret.1);
}
assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]);
harness.key_press_modifiers(ctrl, Key::ArrowRight);
harness.run_steps(1);
assert_eq!(caret(&harness).caret, (0, 5));
// Ctrl+Shift+Right selects to the next stops.
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
harness.run_steps(1);
let t = caret(&harness);
assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12)));
// Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one
// before the caret.
harness.key_press_modifiers(ctrl, Key::Delete);
harness.run_steps(1);
assert_eq!(title_text(harness.state()), "Helloworld. Foo bar");
harness.key_press(Key::End);
harness.key_press_modifiers(ctrl, Key::Backspace);
harness.run_steps(1);
assert_eq!(title_text(harness.state()), "Helloworld. Foo ");
// Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends.
harness.key_press(Key::Enter);
harness.event(egui::Event::Text("Next".into()));
harness.key_press_modifiers(ctrl, Key::ArrowUp);
harness.run_steps(1);
assert_eq!(caret(&harness).caret, (1, 0));
harness.key_press_modifiers(ctrl, Key::ArrowUp);
harness.run_steps(1);
assert_eq!(caret(&harness).caret, (0, 0));
harness.key_press_modifiers(ctrl, Key::ArrowDown);
harness.run_steps(1);
assert_eq!(caret(&harness).caret, (1, 0));
harness.key_press_modifiers(ctrl_shift, Key::Home);
harness.run_steps(1);
let t = caret(&harness);
assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0)));
// Ctrl+Shift+> (egui reports the Period key) grows the selected text's font.
let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64();
let before = size(&mut harness);
harness.key_press_modifiers(ctrl_shift, Key::Period);
harness.run_steps(1);
let after = size(&mut harness).unwrap();
assert!(after > before.unwrap_or(0.0), "{before:?} → {after}");
harness.key_press_modifiers(ctrl, Key::OpenBracket);
harness.run_steps(1);
assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks");
}
#[test]
fn home_needs_an_open_presentation() {
let mut app = SlideApp::new(Session::new(), Services::default());
+1 -67
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" => selection(app, ui),
"selection" => crate::selpane::show(app, ui),
"comments" => comments(app, ui),
"designer" => designer(app, ui),
_ => {}
@@ -549,72 +549,6 @@ 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
@@ -0,0 +1,412 @@
//! 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
@@ -0,0 +1,255 @@
//! 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());
}
+36 -1
View File
@@ -2,6 +2,8 @@
> **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-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **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
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
@@ -29,7 +31,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 | 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 |
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
| Comments pane (threads) | yes | yes |
| Design Ideas (Designer) | yes (cloud) | no |
@@ -64,6 +66,35 @@ group/ungroup, bold/italic/underline, alignment, font size, list levels, Format
start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
precision without snapping, F6 pane cycling, ribbon KeyTips (Windows).
### Text editing keys
Observed black-box in PowerPoint for Windows (Microsoft 365, October 2026) on synthetic text, and
pinned by tests in `crates/model/src/words.rs`, `crates/model/src/edit.rs` and
`crates/engine/src/tests_keys.rs` (#268). Windows and Linux use Ctrl; the Mac uses Option for words
and Cmd for line and text ends (`Session::mac_keys`).
| Keys | Behaviour |
|---|---|
| Ctrl+Left / Right | Previous / next word stop. A stop is the start of every word, punctuation run, symbol run, tab, line break and CJK character, plus the paragraph's end; spaces belong to the word before them. `it's`, `baz-qux`, `a-`, `3.14`, `1,000`, `3:45` are one word; `e.g.`, `a_b`, `x@y.com`, `dogs'` are not. Every CJK ideograph, kana and Hangul syllable is a word. Combining marks and emoji sequences never split. With a selection: Right goes on from its end, Left from its start |
| Ctrl+Shift+Left / Right | Moves the selection's moving end by the same stops |
| Ctrl+Up / Down | Start of the paragraph (then of the previous one) / start of the next paragraph (end of the text in the last one); Shift extends |
| Ctrl+Backspace | Deletes back to the previous stop (word plus its trailing spaces); at a paragraph's start joins it to the previous paragraph |
| Ctrl+Delete | Inside a word: the rest of it. On spaces after a word: the spaces and the next word. At a word's start: the word and its spaces. Then smart spacing: spaces left before closing punctuation (`ab cd, ef` → `ab, ef`) or at the paragraph's end after a word go; sentence punctuation left touching a word gets a space (`cd...ef gh` → `cd... gh`). At a paragraph's end joins the next paragraph |
| Ctrl+Backspace / Delete with a selection | Leave the selection and delete the word part before / after its start |
| Right-to-left paragraph | Left and Right (with or without Ctrl) swap: Left goes forward in the text. Arabic or Hebrew words in a left-to-right paragraph move in logical order |
| Home / End | Start / end of the visual line. End on a wrapped line goes past its trailing space and the caret stays drawn at that line's end; End again stays; Home from there returns to the same line's start |
| Ctrl+Home / End | Start / end of the text; Shift extends |
| Ctrl+Shift+> / <, Ctrl+] / [ | Next / previous size in the font size list (18 → 20 → 24) |
| Ctrl+B / I / U, Ctrl+Space | With a caret strictly inside a word, the whole word (without trailing spaces); at a word's edge, only what is typed next |
| Ctrl+E / L / R / J | Center / left / right / justify the paragraphs in the selection |
| Shift+F3 | Cycles lowercase → Capitalize Each Word → UPPERCASE → lowercase; other mixes go to UPPERCASE first. A caret in a word changes that word and moves to its start |
| Shift+Enter | Line break |
| Double-click / triple-click | The word without its trailing spaces (a CJK character on its own) / the paragraph including its paragraph mark |
Quirks kept on purpose because PowerPoint has them: Ctrl+Left (not Ctrl+Right) also stops at
each no-break or ideographic space, and treats an emoji followed by punctuation as one word. Tab, Shift+Tab and
Alt+Shift+arrows in text are handled elsewhere (#78, #93).
## Visual fidelity
Colours, spacing, ribbon card and tab geometry follow the observations in
@@ -89,4 +120,8 @@ 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-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |