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>
This commit is contained in:
Brandon Thomas 2026-10-12 22:00:37 -04:00
parent 9d571ef644
commit 4e780d52cc
15 changed files with 1445 additions and 84 deletions

1
Cargo.lock generated
View File

@ -1063,6 +1063,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"unicode-segmentation",
]
[[package]]

View File

@ -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) |

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 {

View File

@ -39,6 +39,17 @@ 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),
@ -232,7 +243,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 +514,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 +549,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 +567,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 +620,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 +673,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()

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()?;

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;

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();
}
}
}
}
}
}

View File

@ -14,3 +14,4 @@ deckcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
unicode-segmentation = { workspace = true }

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");
}
}

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;

420
crates/model/src/words.rs Normal file
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));
}
}

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;

View File

@ -513,6 +513,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
}
// After End on a wrapped line the caret is drawn at that line's end.
let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
if blink && let Some((cx, top, bot)) = l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff) {
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
}
}
}
// Table cell editing: outline the cell.

View File

@ -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());

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
@ -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 |