mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
* Tables: select, insert and move with the mouse; double-click a border to AutoFit Like Word, on top of the border resizing: - Selection areas: just above a column the pointer becomes a down arrow, left of a row the row arrow, along a cell's left edge (inside it) the cell arrow. A click selects the column, row or cell; a drag selects every one up to the pointer. Selected cells are shaded as cells, empty ones too. - Insert buttons: left of a row border and above a column border a "+" shows with the border marked; a click adds a row or column there and puts the caret in it. - Move handle past the table's top-left corner: a click selects the table; a drag shows a dashed outline and, on release, the table floats where it is dropped with the text wrapping around it (Word's behaviour). One undo step. Nested tables and tables outside the main text are only selected. - A double-click on a column border fits the column left of it to its contents (the right neighbour gives or takes the room; on the right edge the table's width changes, up to the right margin). - Table Layout > Select > Select Column (catalog 418/449). Whole-cell selection: a column (or a rectangle of cells over several rows) is not one run of the text, so selecting it makes a block of its cells' paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment, Delete, typing, copy and numbering act on those cells only (a numbered column is one list); table commands (shading, merge, delete columns) take the rectangle; Tab moves on without clearing it; Undo brings the selection back. Cells of one row, or whole rows, stay an ordinary stream selection. The format painter no longer clears a block's text (edit.pasteFormat was handled like a paste). The mini toolbar stays hidden over whole-cell selections and during these drags: pinned above the selection it covered the column areas, the "+" buttons and the move handle. Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt, table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so agents and the control channel can do the same) and table.selectColumn; hit testing reuses table_resize's drawn geometry, and borders keep priority for resizing. 10 engine tests (areas, a column edited cell by cell, numbering, rows/cells/drags, insert buttons, move, fit, format painter, hostile params). UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse handler (no extra ui.interact areas, so clicks in a cell's text still place the caret); kittest tests with real pointer events: column click, row drag, "+" button, move-handle drag in one undo step, double-click fit. New labels translated in all eleven catalogs. Progress docs updated: gaps, ui-parity, target-app-parity, ROADMAP, parity checklist. `cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tables: a selected column's word count is its cells'; merged cells shade once With whole cells selected (a column, or a rectangle that isn't one run of the text) the status bar said "12 of 23 words": `Session::selected_text` took everything between the selection's ends. It now takes each piece's own text, a line each, for a column block too, so the status bar, Word Count and the other callers see what is selected. Since #669 a vertically merged cell is one box as tall as its rows, with a `covered` box for each continuation row; shading selected cells skips those, so a merged cell isn't shaded twice. `selected_paths` follows main's `para_paths_in` (#418): a selection ending at the start of the next paragraph doesn't take it. - Test: a selected column's text is its three cells' and Word Count says three words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tables: double-click AutoFit and the "+" buttons work as in Word Checked against Word (2019, Windows): - A double-click on a column border fits the column left of it to its contents and the columns right of it keep their widths, moving with the border, so the table narrows or widens (up to the right margin). The right neighbour used to give or take the difference. - A "+" insert button selects the new row or column (the caret used to go into it); typing then goes into its first cell. If the table can't take another row or column, nothing changes. - A double-click along a cell's left edge (the cell selection area) selects its row. - Tests: engine (fit keeps the columns on the right; insert selects the new row or column, typing lands in its first cell) and kittest (real double-click on a border; "+" selects the row; double-click by a cell's edge selects the row). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
177 lines
5.5 KiB
Rust
177 lines
5.5 KiB
Rust
//! 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 file_access;
|
||
pub mod io;
|
||
mod io_ext;
|
||
pub mod keymap;
|
||
pub mod math_gallery;
|
||
pub mod print;
|
||
pub mod ribbon;
|
||
pub mod sample;
|
||
mod session;
|
||
pub mod speech;
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use serde_json::Value;
|
||
|
||
pub use io::Password;
|
||
pub use keymap::KeyMap;
|
||
pub use ribbon::RibbonLayout;
|
||
pub use session::{
|
||
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
|
||
};
|
||
pub use wordcraft_doc as doc;
|
||
pub use wordcraft_geom::Region;
|
||
pub use wordcraft_layout as layout;
|
||
pub use wordcraft_proof as proof;
|
||
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_bidi;
|
||
#[cfg(test)]
|
||
mod tests_fonts;
|
||
#[cfg(test)]
|
||
mod tests_typing;
|
||
#[cfg(test)]
|
||
mod tests_wordart;
|