From 6334d689d2d245acebe7d9e3cc793741e7094474 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:09:50 -0400 Subject: [PATCH] Text: Ctrl+arrows, Ctrl+Backspace/Delete and other text keys work like PowerPoint (#268) (#333) * 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) * rustfmt * Canvas: one caret, drawn at a wrapped line's end after End Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/engine/src/cmd/format.rs | 44 +++- crates/engine/src/cmd/text.rs | 76 ++++-- crates/engine/src/keys.rs | 90 ++++++- crates/engine/src/lib.rs | 19 ++ crates/engine/src/tests_keys.rs | 404 ++++++++++++++++++++++++++++++ crates/model/Cargo.toml | 1 + crates/model/src/edit.rs | 293 ++++++++++++++++++---- crates/model/src/lib.rs | 1 + crates/model/src/words.rs | 420 ++++++++++++++++++++++++++++++++ crates/text/src/lib.rs | 58 ++++- crates/ui-egui/src/lib.rs | 83 +++++++ docs/ui-parity.md | 33 +++ 14 files changed, 1440 insertions(+), 85 deletions(-) create mode 100644 crates/engine/src/tests_keys.rs create mode 100644 crates/model/src/words.rs diff --git a/Cargo.lock b/Cargo.lock index e8c9398..f4d5347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 0f1e02f..c93e1fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 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) | diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f02a9a2..c68d124 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -49,7 +49,15 @@ pub fn specs() -> Vec { 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 { } fn case(s: &mut Session, p: &Value) -> Result { - 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 { 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::>().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 { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 856f7ab..1b2cb2d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -39,6 +39,16 @@ pub fn specs() -> Vec { 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 { } 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 { 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 { 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 { + 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 { 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 { 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 { @@ -633,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result { 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() diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index cbfc705..665cc22 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -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, ¶ms)?; + 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 { let st = self.doc()?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f6f1598..c2b56c7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// Animated GIF pictures paused with `media.gifPlay` (the others play). pub gif_paused: std::collections::HashSet, + /// 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; diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs new file mode 100644 index 0000000..926c428 --- /dev/null +++ b/crates/engine/src/tests_keys.rs @@ -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 { + 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![(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)> = 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(); + } + } + } + } + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index cb6b4f1..2894f00 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -14,3 +14,4 @@ deckcraft-color = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +unicode-segmentation = { workspace = true } diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index dcdc508..af082b8 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -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 = 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(¶_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 = 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 = 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"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index c3f0f78..c5e307d 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -16,6 +16,7 @@ pub mod style; pub mod table; pub mod text; pub mod theme; +pub mod words; use std::sync::Arc; diff --git a/crates/model/src/words.rs b/crates/model/src/words.rs new file mode 100644 index 0000000..54b2365 --- /dev/null +++ b/crates/model/src/words.rs @@ -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 { + let mut out: Vec = Vec::new(); + let mut firsts: Vec = 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 = 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 { + 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 { + let cs = clusters(text); + let chars: Vec = 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 { + 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 { + 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 = 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 = 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 { + let chars: Vec = 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> = (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)); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 791f257..ba58f92 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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 { + 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 = None; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 25a8323..e536878 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1036,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()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7ac7318..8a57ed4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 @@ -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 @@ -91,4 +122,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | 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 |