Files
wordcraft/crates/engine/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 869ac16110 Typing parity with Word: lists, AutoFormat and AutoCorrect as you type (#204)
* Typing parity with Word: lists, AutoFormat and AutoCorrect as you type

Typed side by side with Word (same keystrokes, results read back) and matched it:

- Enter after a list no longer gets stuck: the paragraph that leaves a list goes back to
  Normal, and later Enters make blank paragraphs (the `num: 0` marker counted as a list item,
  so every Enter re-ran "leave the list" and did nothing; the list indent also stuck to every
  following paragraph).
- Enter on an empty nested item moves it up a level before leaving the list.
- Second Backspace at the start of an un-numbered list paragraph returns it to Normal
  instead of joining it to the paragraph above.
- "1. " always starts numbering at 1; "a. " and "I. " start lettered/roman lists; "- " makes
  a dash bullet.
- Undo right after an AutoFormat/AutoCorrect change undoes only that change.
- AutoCorrect: sentence capitals (with abbreviation/initial exceptions), runs on Enter too,
  "word -- word" en dash, "wait..." ellipsis, 1/2 1/4 3/4, ordinal superscripts, web
  addresses become links.
- Border lines: Enter after ---, ===, ***, ___, ~~~, ### draws a bottom border.
- Layout: no line break right after a slash ("and/or", URLs), as in Word.
- ui_shot: UI_SHOT_FULL=1 prints whole replies for scripted checks.

Regression tests in crates/engine/src/tests_typing.rs and the layout tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Typing parity: record Word's observed typing rules, two more tests

docs/typing-parity.md lists what Word does for each key sequence (lists, Backspace, Tab,
AutoCorrect, AutoFormat, slash line breaks) with the test that pins it, the known gaps, and how
to compare without taking over the shared machine. CLAUDE.md/AGENTS.md point at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:52:10 -04:00

158 lines
5.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordCraft engine: the editing session and its commands.
//!
//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id
//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a
//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the
//! CLI, the JSON control channel and the MCP server all call [`Session::run`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod catalog;
pub mod cmd;
pub mod io;
mod io_ext;
pub mod sample;
mod session;
use std::collections::HashMap;
use serde_json::Value;
pub use session::{FindState, Selection, Session, ViewState};
pub use wordcraft_doc as doc;
pub use wordcraft_layout as layout;
pub use wordcraft_render as render;
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CmdError {
#[error("unknown command `{0}`")]
Unknown(String),
#[error("bad parameters: {0}")]
Params(String),
#[error("not available: {0}")]
Disabled(String),
#[error("{0}")]
Failed(String),
}
impl From<wordcraft_doc::DocError> for CmdError {
fn from(e: wordcraft_doc::DocError) -> Self {
CmdError::Failed(e.to_string())
}
}
pub type CmdResult = Result<Value, CmdError>;
/// A registered command.
#[derive(Clone)]
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`…
pub location: &'static str,
/// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS.
pub shortcut: &'static str,
/// Parameter description for agents (`{"size": number}`).
pub params: &'static str,
pub mutates: bool,
/// `None` = enabled; `Some(reason)` = disabled.
pub enabled: fn(&Session) -> Option<&'static str>,
pub run: fn(&mut Session, &Value) -> CmdResult,
}
fn always(_: &Session) -> Option<&'static str> {
None
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self {
CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run }
}
pub const fn key(mut self, s: &'static str) -> Self {
self.shortcut = s;
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
/// Doesn't change the document (navigation, view, export).
pub const fn pure(mut self) -> Self {
self.mutates = false;
self
}
pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self {
self.enabled = f;
self
}
}
/// All commands by id.
pub struct Registry {
specs: Vec<CommandSpec>,
by_id: HashMap<&'static str, usize>,
}
impl Registry {
pub fn new(specs: Vec<CommandSpec>) -> Self {
let mut by_id = HashMap::new();
for (i, s) in specs.iter().enumerate() {
by_id.insert(s.id, i);
}
Registry { specs, by_id }
}
pub fn get(&self, id: &str) -> Option<&CommandSpec> {
self.by_id.get(id).and_then(|i| self.specs.get(*i))
}
pub fn all(&self) -> &[CommandSpec] {
&self.specs
}
/// Command list as JSON (for agents: `commands` / `list_commands`).
pub fn describe(&self) -> Value {
Value::Array(
self.specs
.iter()
.map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates}))
.collect(),
)
}
/// The command bound to a shortcut, if any.
pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> {
self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key)))
}
}
/// Parameter helpers.
pub mod p {
use serde_json::Value;
use crate::CmdError;
pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> {
v.get(k).and_then(Value::as_str)
}
pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> {
str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required")))
}
pub fn f32(v: &Value, k: &str) -> Option<f32> {
v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite())
}
pub fn req_f32(v: &Value, k: &str) -> Result<f32, CmdError> {
f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required")))
}
pub fn u64(v: &Value, k: &str) -> Option<u64> {
v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64)))
}
pub fn bool(v: &Value, k: &str) -> Option<bool> {
v.get(k).and_then(Value::as_bool)
}
/// The `value` param, or the first positional-ish string.
pub fn value(v: &Value) -> Option<&Value> {
v.get("value")
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_typing;