266 lines
12 KiB
Rust
266 lines
12 KiB
Rust
//! Keyboard: text-editing keys while editing text, nudges and shortcuts otherwise. Shared by the
|
|
//! UI, the control channel and MCP so a key does the same thing everywhere.
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::tools::Mods;
|
|
use crate::{Result, Session};
|
|
|
|
/// Does a shortcut like `Cmd+Shift+]` match `key` + `mods`?
|
|
pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
|
let parts: Vec<&str> = shortcut.split('+').collect();
|
|
let (k, ms) = match parts.as_slice() {
|
|
[.., "", ""] => ("+", &parts[..parts.len().saturating_sub(2)]),
|
|
_ => match parts.split_last() {
|
|
Some((k, ms)) => (*k, ms),
|
|
None => return false,
|
|
},
|
|
};
|
|
let has = |m: &str| ms.iter().any(|x| x.eq_ignore_ascii_case(m));
|
|
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
|
}
|
|
|
|
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`, `]`).
|
|
/// Returns `{handled: bool, …}`.
|
|
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
|
self.end_composition();
|
|
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
|
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
|
let k = key.to_ascii_lowercase();
|
|
let k = k.trim_start_matches("arrow");
|
|
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
|
return self.finish_freeform(false);
|
|
}
|
|
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" => {
|
|
// Tab at the start of a paragraph changes the list level; elsewhere it types a tab.
|
|
let at_start = self.active().and_then(|d| d.selection.text.as_ref()).is_some_and(|t| t.ordered().0.1 == 0 || t.is_range());
|
|
let in_cell = self.active().and_then(|d| d.selection.text.as_ref()).is_some_and(|t| t.cell.is_some());
|
|
if in_cell {
|
|
Some(self.next_cell(mods.shift))
|
|
} else if at_start {
|
|
Some(self.execute(if mods.shift { "format.outdent" } else { "format.indent" }, &json!({})))
|
|
} else {
|
|
Some(self.execute("text.insert", &json!({"text": "\t"})))
|
|
}
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(r) = r {
|
|
r?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
}
|
|
if !editing && has_shapes && !mods.cmd {
|
|
let step = if mods.alt { 1.0 } else { 6.0 };
|
|
let d = match k {
|
|
"left" => Some((-step, 0.0)),
|
|
"right" => Some((step, 0.0)),
|
|
"up" => Some((0.0, -step)),
|
|
"down" => Some((0.0, step)),
|
|
_ => None,
|
|
};
|
|
if let Some((dx, dy)) = d {
|
|
self.execute("shape.move", &json!({"dx": dx, "dy": dy}))?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
match k {
|
|
"backspace" | "delete" => {
|
|
self.execute("edit.delete", &json!({}))?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
"enter" | "return" | "f2" => {
|
|
self.execute("text.edit", &json!({}))?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
"escape" | "esc" => {
|
|
self.execute("edit.deselect", &json!({}))?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
"tab" => {
|
|
self.cycle_selection(!mods.shift)?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if !editing && !has_shapes && !mods.cmd {
|
|
let r = match k {
|
|
"pagedown" | "down" | "right" => Some("slide.next"),
|
|
"pageup" | "up" | "left" => Some("slide.previous"),
|
|
"home" => Some("slide.first"),
|
|
"end" => Some("slide.last"),
|
|
"enter" | "return" => Some("slide.new"),
|
|
"tab" => {
|
|
self.cycle_selection(!mods.shift)?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(id) = r {
|
|
self.execute(id, &json!({}))?;
|
|
return Ok(json!({"handled": true}));
|
|
}
|
|
}
|
|
// Shortcuts from the registry.
|
|
let key_name = match k {
|
|
"enter" | "return" => "Enter",
|
|
"escape" | "esc" => "Escape",
|
|
"delete" => "Delete",
|
|
"backspace" => "Backspace",
|
|
"pageup" => "PageUp",
|
|
"pagedown" => "PageDown",
|
|
"home" => "Home",
|
|
"end" => "End",
|
|
"tab" => "Tab",
|
|
"space" | " " => "Space",
|
|
_ => key,
|
|
};
|
|
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 {
|
|
let r = self.execute(id, &json!({}))?;
|
|
return Ok(json!({"handled": true, "command": id, "result": r}));
|
|
}
|
|
Ok(json!({"handled": false}))
|
|
}
|
|
|
|
/// 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()?;
|
|
if st.selection.text.is_none() {
|
|
let Some(id) = st.selection.shapes.first().copied() else { return Ok(json!({"handled": false})) };
|
|
if st.selection.shapes.len() != 1 {
|
|
return Ok(json!({"handled": false}));
|
|
}
|
|
// Typing on a selected shape replaces its text (PowerPoint behaviour).
|
|
self.execute("text.edit", &json!({"id": id}))?;
|
|
self.execute("text.selectAll", &json!({}))?;
|
|
}
|
|
self.execute("text.insert", &json!({"text": text}))
|
|
}
|
|
|
|
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
|
|
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
|
|
/// `active` is the part being converted, in characters of `text`.
|
|
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
|
|
if !self.start_typing(text)? {
|
|
return Ok(json!({"handled": false}));
|
|
}
|
|
let mut p = json!({"text": text});
|
|
if let Some((a, b)) = active {
|
|
p["active"] = json!([a, b]);
|
|
}
|
|
self.execute("text.compose", &p)
|
|
}
|
|
|
|
/// An input method's result (#101): replaces the composition as one undo step.
|
|
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
|
|
if !self.start_typing(text)? {
|
|
return Ok(json!({"handled": false}));
|
|
}
|
|
self.execute("text.commit", &json!({"text": text}))
|
|
}
|
|
|
|
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
|
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
|
/// and nothing being edited).
|
|
fn start_typing(&mut self, text: &str) -> Result<bool> {
|
|
let st = self.doc()?;
|
|
if st.selection.text.is_some() {
|
|
return Ok(true);
|
|
}
|
|
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
|
|
if st.selection.shapes.len() != 1 || text.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
self.execute("text.edit", &json!({"id": id}))?;
|
|
self.execute("text.selectAll", &json!({}))?;
|
|
Ok(true)
|
|
}
|
|
|
|
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
|
let st = self.doc()?;
|
|
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
|
if ids.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let cur = st.selection.shapes.first().and_then(|c| ids.iter().position(|x| x == c));
|
|
let n = ids.len();
|
|
let next = match cur {
|
|
Some(i) if forward => (i + 1) % n,
|
|
Some(i) => (i + n - 1) % n,
|
|
None if forward => 0,
|
|
None => n - 1,
|
|
};
|
|
let id = ids.get(next).copied();
|
|
self.select(|_, sel| {
|
|
sel.shapes = id.into_iter().collect();
|
|
sel.text = None;
|
|
})
|
|
}
|
|
|
|
fn next_cell(&mut self, back: bool) -> Result<Value> {
|
|
let st = self.doc()?;
|
|
let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) };
|
|
let Some((r, c)) = t.cell else { return Ok(Value::Null) };
|
|
let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) };
|
|
let (nr, nc) = (tb.n_rows(), tb.n_cols());
|
|
let idx = r * nc + c;
|
|
if !back && idx + 1 >= nr * nc {
|
|
// Tab in the last cell adds a row.
|
|
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?;
|
|
return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]}));
|
|
}
|
|
let ni = if back { idx.saturating_sub(1) } else { idx + 1 };
|
|
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?;
|
|
self.execute("text.selectAll", &json!({}))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn shortcut_parsing() {
|
|
let m = |shift, alt, cmd| Mods { shift, alt, cmd };
|
|
assert!(shortcut_matches("Cmd+B", "b", m(false, false, true)));
|
|
assert!(!shortcut_matches("Cmd+B", "b", m(true, false, true)));
|
|
assert!(shortcut_matches("Cmd+Shift+]", "]", m(true, false, true)));
|
|
assert!(shortcut_matches("Cmd+Shift+.", ".", m(true, false, true)));
|
|
assert!(shortcut_matches("Delete", "Delete", m(false, false, false)));
|
|
}
|
|
|
|
#[test]
|
|
fn keys_drive_editing() {
|
|
let mut s = Session::with_new();
|
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
|
s.execute("edit.select", &json!({"ids": [title]})).unwrap();
|
|
s.type_text("Hi").unwrap();
|
|
s.key("Enter", Mods::default()).unwrap();
|
|
s.type_text("there").unwrap();
|
|
s.key("Backspace", Mods::default()).unwrap();
|
|
s.key("Escape", Mods::default()).unwrap();
|
|
let t = s.execute("text.get", &json!({"id": title})).unwrap();
|
|
assert_eq!(t["text"], "Hi\nther");
|
|
let r = s.key("b", Mods { cmd: true, ..Default::default() }).unwrap();
|
|
assert_eq!(r["command"], "format.bold");
|
|
s.key("Right", Mods::default()).unwrap();
|
|
s.key("Delete", Mods::default()).unwrap();
|
|
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes.len(), 1);
|
|
}
|
|
}
|