feat(protocol): extract improvise-protocol crate
The crate the browser epic hangs off: Command (the wire format), ViewState, RenderCache/CellDisplay/Snapshot, and reduce_view. It depends on improvise-core and serde and nothing else -- no IO, no command layer, no terminal -- and improvise-command now depends on it. ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig, MinibufColor, HELP_PAGE_COUNT and the key types move here. Everything that moved is re-exported from ui::app and command::key, so no existing path changed. The three tickets before this one are what made the move mechanical: h0b evicted the unportable fields, a2x added the derives, 8uz took the cursor out of the shared View. Command's client->server half is not invented. It is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice -- h0b had already sorted the portable state from the host-local, so the enumeration was done. Effects touching the wizard, staged drill edits, storage or the layout cache are absent by construction. The other half carries projections: already-formatted display values and their labels. One thing had to be added rather than moved. reduce_view runs on a client that holds no Workbook, and ViewState::cursors is keyed by view name, so ViewState needs its own active_view. That makes two copies of "which view is active"; App::switch_to_view and App::install_workbook are the only writers, and the_two_active_view_names_never_diverge guards it. The 8uz characterization test caught the desync the moment it appeared, because it was calling workbook.switch_view directly. Whether the two are *allowed* to diverge -- two clients on different views -- is improvise-g0f, deliberately left open. It changes the shape of improvise-cqq, and nothing here forecloses either answer. reduce_view has 12 tests including a determinism check: folding the same stream twice gives the same state, which is what stops the server's mirror drifting from the client it mirrors. Every Command round-trips through JSON. The crate is in the CI wasm job. Closes improvise-cqi Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
This commit is contained in:
@@ -37,6 +37,8 @@ jobs:
|
||||
run: cargo check -p improvise-core --target wasm32-unknown-unknown
|
||||
- name: improvise-formula
|
||||
run: cargo check -p improvise-formula --target wasm32-unknown-unknown
|
||||
- name: improvise-protocol
|
||||
run: cargo check -p improvise-protocol --target wasm32-unknown-unknown
|
||||
- name: improvise-io
|
||||
run: cargo check -p improvise-io --target wasm32-unknown-unknown
|
||||
- name: improvise-command
|
||||
|
||||
Generated
+10
@@ -795,6 +795,7 @@ dependencies = [
|
||||
"improvise-core",
|
||||
"improvise-formula",
|
||||
"improvise-io",
|
||||
"improvise-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -849,6 +850,15 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "improvise-protocol"
|
||||
version = "0.1.0-rc2"
|
||||
dependencies = [
|
||||
"improvise-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-io", "crates/improvise-command", "crates/gp-tree"]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/gp-tree"]
|
||||
|
||||
[package]
|
||||
name = "improvise"
|
||||
|
||||
+16
-4
@@ -6,9 +6,10 @@ Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
|
||||
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
|
||||
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
||||
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
|
||||
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). No ratatui/crossterm.
|
||||
- `improvise-protocol` — the wire vocabulary and the portable session slice: `Command`, `ViewState`, `RenderCache`, `reduce_view`, key types. Depends only on `improvise-core` + serde. No IO, no command layer, no terminal.
|
||||
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). Depends on `improvise-protocol`. No ratatui/crossterm.
|
||||
|
||||
The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`.
|
||||
The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`. `ui::app` re-exports the protocol's `AppMode`, `ViewState`, `ViewCursor`, `ViewFrame`, `Panel`, `MinibufferConfig`, `MinibufColor` and `HELP_PAGE_COUNT`, and `command::key` re-exports the protocol's key types, so every pre-existing path still resolves.
|
||||
|
||||
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
|
||||
|
||||
@@ -175,7 +176,9 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
|
||||
|
||||
28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach.
|
||||
|
||||
29. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand.
|
||||
29. **"Which view is active" is stored twice, and the copies must move together.** `Workbook::active_view` is the document's; `ViewState::active_view` is the client's, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi). Only two writers are allowed to touch either — `App::switch_to_view` and `App::install_workbook` — and `the_two_active_view_names_never_diverge` guards it. Calling `workbook.switch_view()` directly desyncs them and the cursor map is then read under the wrong key; that is exactly how the 8uz characterization test broke during the extraction. Whether the two are *allowed* to diverge — two clients on different views — is improvise-g0f, deliberately unanswered here.
|
||||
|
||||
30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand.
|
||||
|
||||
---
|
||||
|
||||
@@ -215,6 +218,15 @@ import/analyzer.rs 292 / 9t Field kind detection (Category/Measure/Time/
|
||||
import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge
|
||||
```
|
||||
|
||||
### `improvise-protocol` (`crates/improvise-protocol/src/`)
|
||||
```
|
||||
lib.rs 472 / 1t ViewState, ViewCursor, ViewFrame, AppMode (16), Panel,
|
||||
MinibufferConfig, MinibufColor, HELP_PAGE_COUNT
|
||||
reduce.rs 377 / 12t Command (the wire format), HelpPage, reduce_view
|
||||
render.rs 90 / 0t CellDisplay, RenderCache, Snapshot
|
||||
key.rs 101 / 3t KeyCode, KeyModifiers, KeyEvent (moved from command/)
|
||||
```
|
||||
|
||||
### Command layer (`crates/improvise-command/src/command/`)
|
||||
```
|
||||
cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
||||
@@ -237,7 +249,7 @@ parse.rs 236 / 19t Script/command-line parser (prefix syntax)
|
||||
### Semantic UI state (`crates/improvise-command/src/ui/`)
|
||||
```
|
||||
ui/effect.rs 2568 / 61t Effect trait, 50+ effect types
|
||||
ui/app.rs 2455 / 70t App, ModelState, ViewState, AppMode (16) + dispatch methods, Panel, handle_key, autosave, HELP_PAGE_COUNT
|
||||
ui/app.rs 2134 / 71t App, ModelState, ViewState, AppMode (16) + dispatch methods, Panel, handle_key, autosave, HELP_PAGE_COUNT
|
||||
ui/cat_tree.rs 156 / 6t Category tree flattener for panel
|
||||
ui/viewport.rs 128 compute_col_widths / row_header_width / visible_cols (pure geometry)
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
|
||||
[dependencies]
|
||||
improvise-core = { path = "../improvise-core" }
|
||||
improvise-protocol = { path = "../improvise-protocol" }
|
||||
improvise-formula = { path = "../improvise-formula" }
|
||||
improvise-io = { path = "../improvise-io" }
|
||||
anyhow = "1"
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
//! and applies them directly.
|
||||
|
||||
pub mod cmd;
|
||||
pub mod key;
|
||||
/// Key types moved to `improvise-protocol` (improvise-cqi) — they ride in
|
||||
/// `ViewState::transient_prefix`, so they must live where the wire types do.
|
||||
/// Re-exported here so `crate::command::key::…` paths keep working.
|
||||
pub mod key {
|
||||
pub use improvise_protocol::key::*;
|
||||
}
|
||||
pub mod keymap;
|
||||
pub mod parse;
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
// The portable half of the session lives in `improvise-protocol`
|
||||
// (improvise-cqi). Re-exported so `crate::ui::app::AppMode` and friends
|
||||
// keep resolving for every existing caller.
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
pub use improvise_protocol::{
|
||||
AppMode, HELP_PAGE_COUNT, MinibufColor, MinibufferConfig, Panel, ViewCursor, ViewFrame,
|
||||
ViewState,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -15,39 +20,12 @@ use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::{Keymap, KeymapSet};
|
||||
use crate::format::parse_number_format;
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::persistence;
|
||||
use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_visible_cols};
|
||||
use crate::view::GridLayout;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// Number of pages in the Help screen. Lives here (with the `help_page`
|
||||
/// state it bounds) so help-page effects stay terminal-independent; the
|
||||
/// help renderer in the root crate imports it.
|
||||
pub const HELP_PAGE_COUNT: usize = 5;
|
||||
|
||||
/// Where one client is looking within one view: the selected cell and the
|
||||
/// scroll offsets that keep it on screen.
|
||||
///
|
||||
/// This is per-client state (improvise-8uz). It used to live on `View`,
|
||||
/// inside the shared workbook, which meant one client scrolling moved
|
||||
/// everyone's viewport. `View` no longer carries it at all — nothing
|
||||
/// persisted it, so there was no saved-position behaviour to preserve.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewCursor {
|
||||
/// Selected cell, as (row index, column index).
|
||||
pub selected: (usize, usize),
|
||||
pub row_offset: usize,
|
||||
pub col_offset: usize,
|
||||
}
|
||||
|
||||
/// A saved view+mode pair for the navigation stack.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ViewFrame {
|
||||
pub view_name: String,
|
||||
pub mode: AppMode,
|
||||
}
|
||||
|
||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||
/// yet been applied to the model.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -60,287 +38,6 @@ pub struct DrillState {
|
||||
pub pending_edits: std::collections::HashMap<(usize, crate::view::RecordCol), String>,
|
||||
}
|
||||
|
||||
/// Accent color for a minibuffer prompt. Semantic palette only — the
|
||||
/// rendering layer maps each variant to a concrete terminal color, so
|
||||
/// this type carries no rendering-framework dependency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MinibufColor {
|
||||
Green,
|
||||
Cyan,
|
||||
Yellow,
|
||||
}
|
||||
|
||||
/// Display configuration for the bottom-bar minibuffer.
|
||||
/// Carried structurally by text-entry `AppMode` variants.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MinibufferConfig {
|
||||
pub buffer_key: String,
|
||||
pub prompt: String,
|
||||
pub color: MinibufColor,
|
||||
}
|
||||
|
||||
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
||||
/// the panel a mode is focused in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Panel {
|
||||
Formula,
|
||||
Category,
|
||||
View,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
/// Every panel, in the order their toggle keys are bound.
|
||||
pub const ALL: [Panel; 3] = [Panel::Formula, Panel::Category, Panel::View];
|
||||
|
||||
/// The name commands use for this panel — the spelling `parse_panel`
|
||||
/// accepts, so `toggle-panel-and-focus` and `move-panel-cursor` args can
|
||||
/// be generated rather than spelled out per panel.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Panel::Formula => "formula",
|
||||
Panel::Category => "category",
|
||||
Panel::View => "view",
|
||||
}
|
||||
}
|
||||
|
||||
/// The key that toggles focus to this panel, bound in Normal mode and in
|
||||
/// every panel's own keymap.
|
||||
pub fn key(self) -> char {
|
||||
match self {
|
||||
Panel::Formula => 'F',
|
||||
Panel::Category => 'C',
|
||||
Panel::View => 'V',
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode that focuses this panel. Inverse of [`AppMode::panel`] for the
|
||||
/// three top-level panel modes (the sub-modes — `FormulaEdit`,
|
||||
/// `CategoryAdd`, `ItemAdd` — map back to their panel but not forwards).
|
||||
pub fn mode(self) -> AppMode {
|
||||
match self {
|
||||
Panel::Formula => AppMode::FormulaPanel,
|
||||
Panel::Category => AppMode::CategoryPanel,
|
||||
Panel::View => AppMode::ViewPanel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
Editing {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
FormulaEdit {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
FormulaPanel,
|
||||
CategoryPanel,
|
||||
/// Quick-add a new category: Enter adds and stays open, Esc closes.
|
||||
CategoryAdd {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
|
||||
ItemAdd {
|
||||
category: String,
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
ViewPanel,
|
||||
TileSelect,
|
||||
ImportWizard,
|
||||
ExportPrompt {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
/// Vim-style `:` command line
|
||||
CommandMode {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
Help,
|
||||
Quit,
|
||||
/// Records-mode normal: inherits from Normal with records-specific bindings.
|
||||
RecordsNormal,
|
||||
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
|
||||
RecordsEditing {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
}
|
||||
|
||||
impl AppMode {
|
||||
/// Short uppercase name shown in the title bar.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "NORMAL",
|
||||
Self::Editing { .. } => "INSERT",
|
||||
Self::FormulaEdit { .. } => "FORMULA",
|
||||
Self::FormulaPanel => "FORMULAS",
|
||||
Self::CategoryPanel => "CATEGORIES",
|
||||
Self::CategoryAdd { .. } => "NEW CATEGORY",
|
||||
Self::ItemAdd { .. } => "ADD ITEMS",
|
||||
Self::ViewPanel => "VIEWS",
|
||||
Self::TileSelect => "TILES",
|
||||
Self::ImportWizard => "IMPORT",
|
||||
Self::ExportPrompt { .. } => "EXPORT",
|
||||
Self::CommandMode { .. } => "COMMAND",
|
||||
Self::Help => "HELP",
|
||||
Self::Quit => "QUIT",
|
||||
Self::RecordsNormal => "RECORDS",
|
||||
Self::RecordsEditing { .. } => "RECORDS INSERT",
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-sensitive keybinding hint for the status bar.
|
||||
///
|
||||
/// `ExportPrompt` and `Quit` deliberately show nothing: `Quit` is never
|
||||
/// rendered, and `ExportPrompt` has no hint text yet (improvise-1c9).
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => {
|
||||
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
|
||||
}
|
||||
Self::Editing { .. } | Self::RecordsEditing { .. } => {
|
||||
"Enter:commit Tab:commit+right Esc:cancel"
|
||||
}
|
||||
Self::RecordsNormal => "hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd",
|
||||
Self::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||
Self::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
||||
Self::CategoryPanel => {
|
||||
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
|
||||
}
|
||||
Self::CategoryAdd { .. } => {
|
||||
"Enter:add & continue Tab:same Esc:done — type a category name"
|
||||
}
|
||||
Self::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
|
||||
Self::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
||||
Self::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
|
||||
Self::CommandMode { .. } => {
|
||||
":q quit :w save :import :add-cat :formula :show-item :help"
|
||||
}
|
||||
Self::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
||||
Self::Help => "h/l:pages q/Esc:close",
|
||||
Self::ExportPrompt { .. } | Self::Quit => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The side panel this mode is focused in, if any. A panel's sub-modes
|
||||
/// count as that panel, so the panel keeps its active styling while the
|
||||
/// user types into its minibuffer.
|
||||
pub fn panel(&self) -> Option<Panel> {
|
||||
match self {
|
||||
Self::FormulaPanel | Self::FormulaEdit { .. } => Some(Panel::Formula),
|
||||
Self::CategoryPanel | Self::CategoryAdd { .. } | Self::ItemAdd { .. } => {
|
||||
Some(Panel::Category)
|
||||
}
|
||||
Self::ViewPanel => Some(Panel::View),
|
||||
Self::Normal
|
||||
| Self::Editing { .. }
|
||||
| Self::TileSelect
|
||||
| Self::ImportWizard
|
||||
| Self::ExportPrompt { .. }
|
||||
| Self::CommandMode { .. }
|
||||
| Self::Help
|
||||
| Self::Quit
|
||||
| Self::RecordsNormal
|
||||
| Self::RecordsEditing { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the minibuffer config from text-entry modes, if present.
|
||||
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
|
||||
match self {
|
||||
Self::Editing { minibuf, .. }
|
||||
| Self::RecordsEditing { minibuf, .. }
|
||||
| Self::FormulaEdit { minibuf, .. }
|
||||
| Self::CommandMode { minibuf, .. }
|
||||
| Self::CategoryAdd { minibuf, .. }
|
||||
| Self::ItemAdd { minibuf, .. }
|
||||
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for any cell-editing mode (normal or records).
|
||||
pub fn is_editing(&self) -> bool {
|
||||
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
|
||||
}
|
||||
|
||||
pub fn editing() -> Self {
|
||||
Self::Editing {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "edit".into(),
|
||||
prompt: "edit: ".into(),
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn records_editing() -> Self {
|
||||
Self::RecordsEditing {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "edit".into(),
|
||||
prompt: "edit: ".into(),
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this mode is a records-mode variant.
|
||||
pub fn is_records(&self) -> bool {
|
||||
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
|
||||
}
|
||||
|
||||
pub fn formula_edit() -> Self {
|
||||
Self::FormulaEdit {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "formula".into(),
|
||||
prompt: "formula: ".into(),
|
||||
color: MinibufColor::Cyan,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_mode() -> Self {
|
||||
Self::CommandMode {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "command".into(),
|
||||
prompt: ":".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_add() -> Self {
|
||||
Self::CategoryAdd {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "category".into(),
|
||||
prompt: "new category: ".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_add(category: String) -> Self {
|
||||
let prompt = format!("add item to {category}: ");
|
||||
Self::ItemAdd {
|
||||
category,
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "item".into(),
|
||||
prompt,
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_prompt() -> Self {
|
||||
Self::ExportPrompt {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "export".into(),
|
||||
prompt: "export path: ".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Document state slice: the workbook and its IO bookkeeping. Distinct from
|
||||
/// `Workbook` itself (which is pure document semantics in `improvise-core`)
|
||||
/// because `file_path` and `dirty` are persistence-layer concerns.
|
||||
@@ -361,86 +58,6 @@ impl Default for ModelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
|
||||
/// and other per-session state that does not persist to disk.
|
||||
///
|
||||
/// This is the **portable** half of the session: the one thing serialized
|
||||
/// into the Snapshot a newly-connected client receives (improvise-q08). Two
|
||||
/// properties gate membership, and both are load-bearing (improvise-h0b):
|
||||
///
|
||||
/// - **portable** — the type is plain data from the allowlisted crates, with
|
||||
/// no shared-ownership containers. The `ViewState: Send` assertion in the
|
||||
/// tests is the compile-time proxy.
|
||||
/// - **snapshot-safe** — a fresh client inheriting the value is *correct*.
|
||||
/// Staged uncommitted writes and half-open dialogs are not.
|
||||
///
|
||||
/// State failing either test lives on `App` instead, under the
|
||||
/// **session-local** residue tag.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViewState {
|
||||
pub mode: AppMode,
|
||||
pub status_msg: String,
|
||||
/// Cursor and scroll position per view name. Keyed rather than single
|
||||
/// so that switching views still restores where you were in each one —
|
||||
/// the behaviour `View`-owned cursors gave, now per-client
|
||||
/// (improvise-8uz). Absent key means "at the origin".
|
||||
pub cursors: HashMap<String, ViewCursor>,
|
||||
pub search_query: String,
|
||||
pub search_mode: bool,
|
||||
pub formula_panel_open: bool,
|
||||
pub category_panel_open: bool,
|
||||
pub view_panel_open: bool,
|
||||
pub cat_panel_cursor: usize,
|
||||
pub view_panel_cursor: usize,
|
||||
pub formula_cursor: usize,
|
||||
/// Yanked cell value for `p` paste
|
||||
pub yanked: Option<CellValue>,
|
||||
/// Tile select cursor (which category index is highlighted)
|
||||
pub tile_cat_idx: usize,
|
||||
/// View navigation history: views visited before the current one.
|
||||
/// Pushed on SwitchView, popped by `<` (back).
|
||||
pub view_back_stack: Vec<ViewFrame>,
|
||||
/// Views that were "back-ed" from, available for forward navigation (`>`).
|
||||
pub view_forward_stack: Vec<ViewFrame>,
|
||||
/// Current page index in the Help screen (0-based).
|
||||
pub help_page: usize,
|
||||
/// Categories expanded in the category panel tree view.
|
||||
pub expanded_cats: std::collections::HashSet<String>,
|
||||
/// Named text buffers for text-entry modes
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// The prefix key currently armed, for Emacs-style sequences (g→gg,
|
||||
/// y→yy, Z→ZZ). Holds the key that opened the prefix, not the sub-keymap
|
||||
/// it maps to: `KeymapSet` owns the keymaps, and `ViewState` is meant to
|
||||
/// be portable, serializable state (improvise-jc8).
|
||||
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: AppMode::Normal,
|
||||
status_msg: String::new(),
|
||||
cursors: HashMap::new(),
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
formula_panel_open: false,
|
||||
category_panel_open: false,
|
||||
view_panel_open: false,
|
||||
cat_panel_cursor: 0,
|
||||
view_panel_cursor: 0,
|
||||
formula_cursor: 0,
|
||||
yanked: None,
|
||||
tile_cat_idx: 0,
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
help_page: 0,
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_prefix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level TUI application state. Composed of two named slices and a
|
||||
/// residue of fields that don't belong to either:
|
||||
///
|
||||
@@ -530,6 +147,7 @@ impl App {
|
||||
// the caller must gather them explicitly.
|
||||
let none_cats = workbook.active_view().none_cats();
|
||||
workbook.model.recompute_formulas(&none_cats);
|
||||
let active_view_name = workbook.active_view.clone();
|
||||
let layout = {
|
||||
let view = workbook.active_view();
|
||||
GridLayout::with_frozen_records(&workbook.model, view, None)
|
||||
@@ -540,7 +158,10 @@ impl App {
|
||||
file_path,
|
||||
dirty: false,
|
||||
},
|
||||
view_state: ViewState::default(),
|
||||
view_state: ViewState {
|
||||
active_view: active_view_name,
|
||||
..ViewState::default()
|
||||
},
|
||||
wizard: None,
|
||||
drill_state: None,
|
||||
last_autosave: Instant::now(),
|
||||
@@ -558,7 +179,7 @@ impl App {
|
||||
/// This client's cursor in the active view. Absent means the origin, so
|
||||
/// a view visited for the first time opens at (0, 0).
|
||||
pub fn cursor(&self) -> ViewCursor {
|
||||
self.cursor_in(&self.model_state.workbook.active_view)
|
||||
self.view_state.cursor()
|
||||
}
|
||||
|
||||
/// This client's cursor in `view_name`.
|
||||
@@ -572,8 +193,27 @@ impl App {
|
||||
|
||||
/// Mutable cursor for the active view, created at the origin if absent.
|
||||
pub fn cursor_mut(&mut self) -> &mut ViewCursor {
|
||||
let name = self.model_state.workbook.active_view.clone();
|
||||
self.view_state.cursors.entry(name).or_default()
|
||||
self.view_state.cursor_mut()
|
||||
}
|
||||
|
||||
/// Replace the whole document. The client's view slice is scoped to the
|
||||
/// old one — its cursor map is keyed by that document's view names —
|
||||
/// so it is reset to the incoming document's active view rather than
|
||||
/// carried over (improvise-8uz, improvise-cqi).
|
||||
pub fn install_workbook(&mut self, workbook: Workbook) {
|
||||
self.view_state.active_view = workbook.active_view.clone();
|
||||
self.view_state.cursors.clear();
|
||||
self.model_state.workbook = workbook;
|
||||
}
|
||||
|
||||
/// Point the view slice at `name` and switch the workbook to it. The
|
||||
/// two copies of "which view is active" must move together: the
|
||||
/// workbook's is the document's, `ViewState`'s is the client's, and
|
||||
/// the client's is what keys the cursor map (improvise-cqi).
|
||||
pub fn switch_to_view(&mut self, name: &str) {
|
||||
if self.model_state.workbook.switch_view(name).is_ok() {
|
||||
self.view_state.active_view = name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the active view's cursor back to the origin. Called when the
|
||||
@@ -792,6 +432,8 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::cell::CellValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Every mode the user can be in, so the dispatch-method tests below
|
||||
/// exercise all arms. `Quit` is included even though it never renders.
|
||||
@@ -954,6 +596,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-cqi: "which view is active" is now stored twice — on the
|
||||
/// `Workbook` (the document's) and on `ViewState` (the client's, which
|
||||
/// keys the cursor map). Every path that changes one must change the
|
||||
/// other, or the cursor map is read under the wrong key. `App` owns
|
||||
/// both writers: `switch_to_view` and `install_workbook`.
|
||||
#[test]
|
||||
fn the_two_active_view_names_never_diverge() {
|
||||
let mut app = two_col_model();
|
||||
app.model_state.workbook.create_view("Second");
|
||||
fn agree(app: &App) {
|
||||
assert_eq!(
|
||||
app.view_state.active_view, app.model_state.workbook.active_view,
|
||||
"view slice and document disagree about the active view"
|
||||
);
|
||||
}
|
||||
agree(&app);
|
||||
|
||||
app.switch_to_view("Second");
|
||||
agree(&app);
|
||||
|
||||
// A switch to a view that does not exist must move neither.
|
||||
app.switch_to_view("Nonexistent");
|
||||
agree(&app);
|
||||
assert_eq!(app.view_state.active_view, "Second");
|
||||
|
||||
let mut incoming = Workbook::new("Other");
|
||||
incoming.create_view("Elsewhere");
|
||||
incoming.switch_view("Elsewhere").unwrap();
|
||||
app.install_workbook(incoming);
|
||||
agree(&app);
|
||||
assert_eq!(app.view_state.active_view, "Elsewhere");
|
||||
assert!(
|
||||
app.view_state.cursors.is_empty(),
|
||||
"a new document does not inherit the old one's cursors"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-8uz: moving the cursor must leave the shared document
|
||||
/// untouched, so that one client scrolling cannot move another's
|
||||
/// viewport. Before the move these three effects wrote into
|
||||
@@ -1031,18 +710,18 @@ mod tests {
|
||||
let mut app = two_col_model();
|
||||
app.model_state.workbook.create_view("Second");
|
||||
|
||||
app.model_state.workbook.switch_view("Default").unwrap();
|
||||
app.switch_to_view("Default");
|
||||
app.cursor_mut().selected = (2, 1);
|
||||
app.cursor_mut().row_offset = 1;
|
||||
|
||||
app.model_state.workbook.switch_view("Second").unwrap();
|
||||
app.switch_to_view("Second");
|
||||
assert_eq!(
|
||||
app.cursor().selected,
|
||||
(0, 0),
|
||||
"a different view starts at its own cursor, not the previous view's"
|
||||
);
|
||||
|
||||
app.model_state.workbook.switch_view("Default").unwrap();
|
||||
app.switch_to_view("Default");
|
||||
assert_eq!(app.cursor().selected, (2, 1));
|
||||
assert_eq!(app.cursor().row_offset, 1);
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ impl Effect for SwitchView {
|
||||
});
|
||||
app.view_state.view_forward_stack.clear();
|
||||
}
|
||||
let _ = app.model_state.workbook.switch_view(&self.0);
|
||||
app.switch_to_view(&self.0);
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -353,7 +353,7 @@ impl Effect for ViewBack {
|
||||
view_name: current,
|
||||
mode: app.view_state.mode.clone(),
|
||||
});
|
||||
let _ = app.model_state.workbook.switch_view(&frame.view_name);
|
||||
app.switch_to_view(&frame.view_name);
|
||||
app.view_state.mode = frame.mode;
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ impl Effect for ViewForward {
|
||||
view_name: current,
|
||||
mode: app.view_state.mode.clone(),
|
||||
});
|
||||
let _ = app.model_state.workbook.switch_view(&frame.view_name);
|
||||
app.switch_to_view(&frame.view_name);
|
||||
app.view_state.mode = frame.mode;
|
||||
}
|
||||
}
|
||||
@@ -913,8 +913,7 @@ impl Effect for AcceptWizardModel {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let built = app.wizard.as_mut().and_then(|w| w.take_built());
|
||||
if let Some((workbook, warnings)) = built {
|
||||
app.model_state.workbook = workbook;
|
||||
app.view_state.cursors.clear();
|
||||
app.install_workbook(workbook);
|
||||
app.view_state.formula_cursor = 0;
|
||||
app.model_state.dirty = true;
|
||||
app.view_state.status_msg = if warnings.is_empty() {
|
||||
@@ -993,8 +992,7 @@ impl Effect for LoadModel {
|
||||
fn apply(&self, app: &mut App) {
|
||||
match crate::persistence::load_from(&*app.storage, &self.0) {
|
||||
Ok(loaded) => {
|
||||
app.model_state.workbook = loaded;
|
||||
app.view_state.cursors.clear();
|
||||
app.install_workbook(loaded);
|
||||
app.view_state.status_msg = format!("Loaded from {}", self.0.display());
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1124,7 +1122,7 @@ impl Effect for ImportJsonHeadless {
|
||||
|
||||
match pipeline.build_model_with_warnings() {
|
||||
Ok((new_workbook, build_warnings)) => {
|
||||
app.model_state.workbook = new_workbook;
|
||||
app.install_workbook(new_workbook);
|
||||
warnings.extend(build_warnings);
|
||||
app.view_state.status_msg = if warnings.is_empty() {
|
||||
"Imported successfully".to_string()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "improvise-protocol"
|
||||
version = "0.1.0-rc2"
|
||||
edition = "2024"
|
||||
description = "Wire vocabulary and portable session state shared by the improvise server and clients"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
|
||||
[dependencies]
|
||||
improvise-core = { path = "../improvise-core" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Portable session state and the wire vocabulary between a server and a
|
||||
//! client (improvise-cqi).
|
||||
//!
|
||||
//! Everything here crosses a process boundary, so the crate depends only on
|
||||
//! `improvise-core` and `serde` — no terminal backend, no command layer, no
|
||||
//! IO. `improvise-command` depends on *this*, never the reverse; that
|
||||
//! direction is what makes the client able to hold the view half without
|
||||
//! pulling in the host.
|
||||
//!
|
||||
//! Two things live here and it is worth being precise about which travels:
|
||||
//!
|
||||
//! - [`ViewState`] is a **projection of the command stream**, not a wire
|
||||
//! message. The server keeps a mirror of each client's view state by
|
||||
//! applying the same commands the client applies, through the shared
|
||||
//! [`reduce_view`]. It is serialized at exactly one point — the bootstrap
|
||||
//! [`Snapshot`] a newly-connected client folds later commands onto
|
||||
//! (improvise-q08). Steady-state traffic is commands only.
|
||||
//! - [`Command`] is the wire format, and travels in both directions: user
|
||||
//! intent from client to server, projections from server to client.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub mod key;
|
||||
mod reduce;
|
||||
pub mod render;
|
||||
|
||||
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
pub use reduce::{Command, HelpPage, reduce_view};
|
||||
pub use render::{CellDisplay, RenderCache, Snapshot};
|
||||
|
||||
use improvise_core::model::cell::CellValue;
|
||||
|
||||
/// Number of pages in the Help screen. Lives here (with the `help_page`
|
||||
/// state it bounds) so help-page effects stay terminal-independent; the
|
||||
/// help renderer in the root crate imports it.
|
||||
pub const HELP_PAGE_COUNT: usize = 5;
|
||||
|
||||
/// Where one client is looking within one view: the selected cell and the
|
||||
/// scroll offsets that keep it on screen.
|
||||
///
|
||||
/// This is per-client state (improvise-8uz). It used to live on `View`,
|
||||
/// inside the shared workbook, which meant one client scrolling moved
|
||||
/// everyone's viewport. `View` no longer carries it at all — nothing
|
||||
/// persisted it, so there was no saved-position behaviour to preserve.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewCursor {
|
||||
/// Selected cell, as (row index, column index).
|
||||
pub selected: (usize, usize),
|
||||
pub row_offset: usize,
|
||||
pub col_offset: usize,
|
||||
}
|
||||
|
||||
/// A saved view+mode pair for the navigation stack.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ViewFrame {
|
||||
pub view_name: String,
|
||||
pub mode: AppMode,
|
||||
}
|
||||
|
||||
/// Accent color for a minibuffer prompt. Semantic palette only — the
|
||||
/// rendering layer maps each variant to a concrete terminal color, so
|
||||
/// this type carries no rendering-framework dependency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MinibufColor {
|
||||
Green,
|
||||
Cyan,
|
||||
Yellow,
|
||||
}
|
||||
|
||||
/// Display configuration for the bottom-bar minibuffer.
|
||||
/// Carried structurally by text-entry `AppMode` variants.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MinibufferConfig {
|
||||
pub buffer_key: String,
|
||||
pub prompt: String,
|
||||
pub color: MinibufColor,
|
||||
}
|
||||
|
||||
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
||||
/// the panel a mode is focused in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Panel {
|
||||
Formula,
|
||||
Category,
|
||||
View,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
/// Every panel, in the order their toggle keys are bound.
|
||||
pub const ALL: [Panel; 3] = [Panel::Formula, Panel::Category, Panel::View];
|
||||
|
||||
/// The name commands use for this panel — the spelling `parse_panel`
|
||||
/// accepts, so `toggle-panel-and-focus` and `move-panel-cursor` args can
|
||||
/// be generated rather than spelled out per panel.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Panel::Formula => "formula",
|
||||
Panel::Category => "category",
|
||||
Panel::View => "view",
|
||||
}
|
||||
}
|
||||
|
||||
/// The key that toggles focus to this panel, bound in Normal mode and in
|
||||
/// every panel's own keymap.
|
||||
pub fn key(self) -> char {
|
||||
match self {
|
||||
Panel::Formula => 'F',
|
||||
Panel::Category => 'C',
|
||||
Panel::View => 'V',
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode that focuses this panel. Inverse of [`AppMode::panel`] for the
|
||||
/// three top-level panel modes (the sub-modes — `FormulaEdit`,
|
||||
/// `CategoryAdd`, `ItemAdd` — map back to their panel but not forwards).
|
||||
pub fn mode(self) -> AppMode {
|
||||
match self {
|
||||
Panel::Formula => AppMode::FormulaPanel,
|
||||
Panel::Category => AppMode::CategoryPanel,
|
||||
Panel::View => AppMode::ViewPanel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
Editing {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
FormulaEdit {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
FormulaPanel,
|
||||
CategoryPanel,
|
||||
/// Quick-add a new category: Enter adds and stays open, Esc closes.
|
||||
CategoryAdd {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
|
||||
ItemAdd {
|
||||
category: String,
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
ViewPanel,
|
||||
TileSelect,
|
||||
ImportWizard,
|
||||
ExportPrompt {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
/// Vim-style `:` command line
|
||||
CommandMode {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
Help,
|
||||
Quit,
|
||||
/// Records-mode normal: inherits from Normal with records-specific bindings.
|
||||
RecordsNormal,
|
||||
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
|
||||
RecordsEditing {
|
||||
minibuf: MinibufferConfig,
|
||||
},
|
||||
}
|
||||
|
||||
impl AppMode {
|
||||
/// Short uppercase name shown in the title bar.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "NORMAL",
|
||||
Self::Editing { .. } => "INSERT",
|
||||
Self::FormulaEdit { .. } => "FORMULA",
|
||||
Self::FormulaPanel => "FORMULAS",
|
||||
Self::CategoryPanel => "CATEGORIES",
|
||||
Self::CategoryAdd { .. } => "NEW CATEGORY",
|
||||
Self::ItemAdd { .. } => "ADD ITEMS",
|
||||
Self::ViewPanel => "VIEWS",
|
||||
Self::TileSelect => "TILES",
|
||||
Self::ImportWizard => "IMPORT",
|
||||
Self::ExportPrompt { .. } => "EXPORT",
|
||||
Self::CommandMode { .. } => "COMMAND",
|
||||
Self::Help => "HELP",
|
||||
Self::Quit => "QUIT",
|
||||
Self::RecordsNormal => "RECORDS",
|
||||
Self::RecordsEditing { .. } => "RECORDS INSERT",
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-sensitive keybinding hint for the status bar.
|
||||
///
|
||||
/// `ExportPrompt` and `Quit` deliberately show nothing: `Quit` is never
|
||||
/// rendered, and `ExportPrompt` has no hint text yet (improvise-1c9).
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => {
|
||||
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
|
||||
}
|
||||
Self::Editing { .. } | Self::RecordsEditing { .. } => {
|
||||
"Enter:commit Tab:commit+right Esc:cancel"
|
||||
}
|
||||
Self::RecordsNormal => "hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd",
|
||||
Self::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||
Self::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
||||
Self::CategoryPanel => {
|
||||
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
|
||||
}
|
||||
Self::CategoryAdd { .. } => {
|
||||
"Enter:add & continue Tab:same Esc:done — type a category name"
|
||||
}
|
||||
Self::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
|
||||
Self::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
||||
Self::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
|
||||
Self::CommandMode { .. } => {
|
||||
":q quit :w save :import :add-cat :formula :show-item :help"
|
||||
}
|
||||
Self::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
||||
Self::Help => "h/l:pages q/Esc:close",
|
||||
Self::ExportPrompt { .. } | Self::Quit => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The side panel this mode is focused in, if any. A panel's sub-modes
|
||||
/// count as that panel, so the panel keeps its active styling while the
|
||||
/// user types into its minibuffer.
|
||||
pub fn panel(&self) -> Option<Panel> {
|
||||
match self {
|
||||
Self::FormulaPanel | Self::FormulaEdit { .. } => Some(Panel::Formula),
|
||||
Self::CategoryPanel | Self::CategoryAdd { .. } | Self::ItemAdd { .. } => {
|
||||
Some(Panel::Category)
|
||||
}
|
||||
Self::ViewPanel => Some(Panel::View),
|
||||
Self::Normal
|
||||
| Self::Editing { .. }
|
||||
| Self::TileSelect
|
||||
| Self::ImportWizard
|
||||
| Self::ExportPrompt { .. }
|
||||
| Self::CommandMode { .. }
|
||||
| Self::Help
|
||||
| Self::Quit
|
||||
| Self::RecordsNormal
|
||||
| Self::RecordsEditing { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the minibuffer config from text-entry modes, if present.
|
||||
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
|
||||
match self {
|
||||
Self::Editing { minibuf, .. }
|
||||
| Self::RecordsEditing { minibuf, .. }
|
||||
| Self::FormulaEdit { minibuf, .. }
|
||||
| Self::CommandMode { minibuf, .. }
|
||||
| Self::CategoryAdd { minibuf, .. }
|
||||
| Self::ItemAdd { minibuf, .. }
|
||||
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for any cell-editing mode (normal or records).
|
||||
pub fn is_editing(&self) -> bool {
|
||||
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
|
||||
}
|
||||
|
||||
pub fn editing() -> Self {
|
||||
Self::Editing {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "edit".into(),
|
||||
prompt: "edit: ".into(),
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn records_editing() -> Self {
|
||||
Self::RecordsEditing {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "edit".into(),
|
||||
prompt: "edit: ".into(),
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this mode is a records-mode variant.
|
||||
pub fn is_records(&self) -> bool {
|
||||
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
|
||||
}
|
||||
|
||||
pub fn formula_edit() -> Self {
|
||||
Self::FormulaEdit {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "formula".into(),
|
||||
prompt: "formula: ".into(),
|
||||
color: MinibufColor::Cyan,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_mode() -> Self {
|
||||
Self::CommandMode {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "command".into(),
|
||||
prompt: ":".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_add() -> Self {
|
||||
Self::CategoryAdd {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "category".into(),
|
||||
prompt: "new category: ".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_add(category: String) -> Self {
|
||||
let prompt = format!("add item to {category}: ");
|
||||
Self::ItemAdd {
|
||||
category,
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "item".into(),
|
||||
prompt,
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_prompt() -> Self {
|
||||
Self::ExportPrompt {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "export".into(),
|
||||
prompt: "export path: ".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
|
||||
/// and other per-session state that does not persist to disk.
|
||||
///
|
||||
/// This is the **portable** half of the session: the one thing serialized
|
||||
/// into the Snapshot a newly-connected client receives (improvise-q08). Two
|
||||
/// properties gate membership, and both are load-bearing (improvise-h0b):
|
||||
///
|
||||
/// - **portable** — the type is plain data from the allowlisted crates, with
|
||||
/// no shared-ownership containers. The `ViewState: Send` assertion in the
|
||||
/// tests is the compile-time proxy.
|
||||
/// - **snapshot-safe** — a fresh client inheriting the value is *correct*.
|
||||
/// Staged uncommitted writes and half-open dialogs are not.
|
||||
///
|
||||
/// State failing either test lives on `App` instead, under the
|
||||
/// **session-local** residue tag.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ViewState {
|
||||
pub mode: AppMode,
|
||||
pub status_msg: String,
|
||||
/// Which view this client is looking at. Duplicated from
|
||||
/// `Workbook::active_view` on purpose: `reduce_view` runs on a client
|
||||
/// that holds no `Workbook`, and `cursors` is keyed by view name, so
|
||||
/// the portable half has to carry its own key (improvise-cqi). The
|
||||
/// host keeps the two in step; whether they are *allowed* to diverge —
|
||||
/// two clients on different views — is improvise-g0f.
|
||||
pub active_view: String,
|
||||
/// Cursor and scroll position per view name. Keyed rather than single
|
||||
/// so that switching views still restores where you were in each one —
|
||||
/// the behaviour `View`-owned cursors gave, now per-client
|
||||
/// (improvise-8uz). Absent key means "at the origin".
|
||||
pub cursors: HashMap<String, ViewCursor>,
|
||||
pub search_query: String,
|
||||
pub search_mode: bool,
|
||||
pub formula_panel_open: bool,
|
||||
pub category_panel_open: bool,
|
||||
pub view_panel_open: bool,
|
||||
pub cat_panel_cursor: usize,
|
||||
pub view_panel_cursor: usize,
|
||||
pub formula_cursor: usize,
|
||||
/// Yanked cell value for `p` paste
|
||||
pub yanked: Option<CellValue>,
|
||||
/// Tile select cursor (which category index is highlighted)
|
||||
pub tile_cat_idx: usize,
|
||||
/// View navigation history: views visited before the current one.
|
||||
/// Pushed on SwitchView, popped by `<` (back).
|
||||
pub view_back_stack: Vec<ViewFrame>,
|
||||
/// Views that were "back-ed" from, available for forward navigation (`>`).
|
||||
pub view_forward_stack: Vec<ViewFrame>,
|
||||
/// Current page index in the Help screen (0-based).
|
||||
pub help_page: usize,
|
||||
/// Categories expanded in the category panel tree view.
|
||||
pub expanded_cats: std::collections::HashSet<String>,
|
||||
/// Named text buffers for text-entry modes
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// The prefix key currently armed, for Emacs-style sequences (g→gg,
|
||||
/// y→yy, Z→ZZ). Holds the key that opened the prefix, not the sub-keymap
|
||||
/// it maps to: `KeymapSet` owns the keymaps, and `ViewState` is meant to
|
||||
/// be portable, serializable state (improvise-jc8).
|
||||
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: AppMode::Normal,
|
||||
status_msg: String::new(),
|
||||
active_view: "Default".to_string(),
|
||||
cursors: HashMap::new(),
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
formula_panel_open: false,
|
||||
category_panel_open: false,
|
||||
view_panel_open: false,
|
||||
cat_panel_cursor: 0,
|
||||
view_panel_cursor: 0,
|
||||
formula_cursor: 0,
|
||||
yanked: None,
|
||||
tile_cat_idx: 0,
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
help_page: 0,
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_prefix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
/// This client's cursor in the active view. Absent means the origin, so
|
||||
/// a view visited for the first time opens at (0, 0).
|
||||
pub fn cursor(&self) -> ViewCursor {
|
||||
self.cursors
|
||||
.get(&self.active_view)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mutable cursor for the active view, created at the origin if absent.
|
||||
pub fn cursor_mut(&mut self) -> &mut ViewCursor {
|
||||
self.cursors.entry(self.active_view.clone()).or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boundary_tests {
|
||||
/// improvise-cqi AC 2: this crate crosses a process boundary, so it must
|
||||
/// not drag a terminal backend with it. A `cargo tree` check would be
|
||||
/// external; this is the part the compiler can hold — the types here
|
||||
/// resolve with only `improvise-core` and `serde` in scope, and adding
|
||||
/// a `ratatui` or `crossterm` dependency would have to be written into
|
||||
/// Cargo.toml deliberately rather than arriving through a `use`.
|
||||
///
|
||||
/// The CI `wasm` job is the other half: a crate that pulled in
|
||||
/// crossterm would stop building for wasm32.
|
||||
#[test]
|
||||
fn the_wire_types_need_nothing_but_core_and_serde() {
|
||||
fn portable<T: serde::Serialize + serde::de::DeserializeOwned>() {}
|
||||
portable::<super::ViewState>();
|
||||
portable::<super::AppMode>();
|
||||
portable::<super::ViewCursor>();
|
||||
portable::<super::ViewFrame>();
|
||||
portable::<super::Panel>();
|
||||
portable::<super::Command>();
|
||||
portable::<super::RenderCache>();
|
||||
portable::<super::CellDisplay>();
|
||||
portable::<super::Snapshot>();
|
||||
portable::<super::KeyCode>();
|
||||
portable::<super::KeyModifiers>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! The wire vocabulary, and the fold that applies it to a client's view.
|
||||
//!
|
||||
//! `Command` is not a fresh invention: its client→server half is the set of
|
||||
//! mutations that actually reach [`ViewState`] — every `EffectKind::ViewOnly`
|
||||
//! effect in `improvise-command` whose target is the portable slice. Effects
|
||||
//! that touch host-local state (the import wizard, staged drill edits, the
|
||||
//! storage backend, the layout cache) are deliberately absent: they are not
|
||||
//! portable, which is why improvise-h0b moved them off `ViewState`.
|
||||
//!
|
||||
//! The server→client half carries projections: already-formatted display
|
||||
//! values and the labels that frame them (improvise-cqq).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use improvise_core::model::cell::CellValue;
|
||||
|
||||
use crate::render::{CellDisplay, RenderCache};
|
||||
use crate::{AppMode, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
|
||||
|
||||
/// One message on the wire, in either direction.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Command {
|
||||
// ── client → server: view-slice intent ──────────────────────────
|
||||
/// Move the cursor within the active view.
|
||||
SetSelected {
|
||||
row: usize,
|
||||
col: usize,
|
||||
},
|
||||
SetRowOffset(usize),
|
||||
SetColOffset(usize),
|
||||
ChangeMode(AppMode),
|
||||
SetStatus(String),
|
||||
SetYanked(Option<CellValue>),
|
||||
SetSearchQuery(String),
|
||||
SetSearchMode(bool),
|
||||
SetBuffer {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
ClearBuffer(String),
|
||||
SetTileCatIdx(usize),
|
||||
SetPanelOpen {
|
||||
panel: Panel,
|
||||
open: bool,
|
||||
},
|
||||
SetPanelCursor {
|
||||
panel: Panel,
|
||||
cursor: usize,
|
||||
},
|
||||
ToggleCatExpand(String),
|
||||
HelpPage(HelpPage),
|
||||
/// Arm a prefix key, or clear the armed one. Held as the *address* of
|
||||
/// the prefix rather than the sub-keymap it names, so the keymaps stay
|
||||
/// on the host side (improvise-jc8).
|
||||
SetTransientPrefix(Option<(KeyCode, KeyModifiers)>),
|
||||
/// The active view changed. Carries the name because a client's cursor
|
||||
/// map is keyed by it (improvise-8uz).
|
||||
SwitchView(String),
|
||||
|
||||
// ── server → client: projections ────────────────────────────────
|
||||
/// Display values for cells, by viewport position.
|
||||
CellsUpdated(Vec<((usize, usize), CellDisplay)>),
|
||||
RowLabelsChanged(Vec<String>),
|
||||
ColLabelsChanged(Vec<String>),
|
||||
ColWidthsChanged(Vec<u16>),
|
||||
/// The grid's shape changed and the client's cache is stale; a
|
||||
/// `RowLabelsChanged`/`ColLabelsChanged` pair follows.
|
||||
ViewportInvalidated,
|
||||
}
|
||||
|
||||
/// Which way a help-page command moves. An enum rather than three variants
|
||||
/// so the bounds check has one home.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HelpPage {
|
||||
Next,
|
||||
Prev,
|
||||
Set(usize),
|
||||
}
|
||||
|
||||
/// Apply one command to a client's view state and render cache.
|
||||
///
|
||||
/// The same function runs on both ends: the client folds it to draw, and
|
||||
/// the server folds it over its mirror of that client's view state, so the
|
||||
/// two cannot drift (improvise-cqq, improvise-m91). It is total and
|
||||
/// deterministic — no IO, no clock, no failure.
|
||||
pub fn reduce_view(view: &mut ViewState, cache: &mut RenderCache, cmd: &Command) {
|
||||
match cmd {
|
||||
Command::SetSelected { row, col } => {
|
||||
view.cursor_mut().selected = (*row, *col);
|
||||
}
|
||||
Command::SetRowOffset(n) => view.cursor_mut().row_offset = *n,
|
||||
Command::SetColOffset(n) => view.cursor_mut().col_offset = *n,
|
||||
Command::ChangeMode(mode) => view.mode = mode.clone(),
|
||||
Command::SetStatus(s) => view.status_msg = s.clone(),
|
||||
Command::SetYanked(v) => view.yanked = v.clone(),
|
||||
Command::SetSearchQuery(q) => view.search_query = q.clone(),
|
||||
Command::SetSearchMode(on) => view.search_mode = *on,
|
||||
Command::SetBuffer { name, value } => {
|
||||
view.buffers.insert(name.clone(), value.clone());
|
||||
}
|
||||
Command::ClearBuffer(name) => {
|
||||
view.buffers.remove(name);
|
||||
}
|
||||
Command::SetTileCatIdx(i) => view.tile_cat_idx = *i,
|
||||
Command::SetPanelOpen { panel, open } => match panel {
|
||||
Panel::Formula => view.formula_panel_open = *open,
|
||||
Panel::Category => view.category_panel_open = *open,
|
||||
Panel::View => view.view_panel_open = *open,
|
||||
},
|
||||
Command::SetPanelCursor { panel, cursor } => match panel {
|
||||
Panel::Formula => view.formula_cursor = *cursor,
|
||||
Panel::Category => view.cat_panel_cursor = *cursor,
|
||||
Panel::View => view.view_panel_cursor = *cursor,
|
||||
},
|
||||
Command::ToggleCatExpand(cat) => {
|
||||
if !view.expanded_cats.remove(cat) {
|
||||
view.expanded_cats.insert(cat.clone());
|
||||
}
|
||||
}
|
||||
Command::HelpPage(step) => {
|
||||
view.help_page = match step {
|
||||
HelpPage::Next => (view.help_page + 1) % HELP_PAGE_COUNT,
|
||||
HelpPage::Prev => (view.help_page + HELP_PAGE_COUNT - 1) % HELP_PAGE_COUNT,
|
||||
HelpPage::Set(n) => (*n).min(HELP_PAGE_COUNT - 1),
|
||||
};
|
||||
}
|
||||
Command::SetTransientPrefix(p) => view.transient_prefix = *p,
|
||||
Command::SwitchView(name) => view.active_view = name.clone(),
|
||||
|
||||
Command::CellsUpdated(changes) => {
|
||||
for ((row, col), display) in changes {
|
||||
if let Some(slot) = cache.cells.get_mut(*row).and_then(|r| r.get_mut(*col)) {
|
||||
*slot = display.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::RowLabelsChanged(labels) => {
|
||||
cache.row_labels = labels.clone();
|
||||
cache.fit_to_labels();
|
||||
}
|
||||
Command::ColLabelsChanged(labels) => {
|
||||
cache.col_labels = labels.clone();
|
||||
cache.fit_to_labels();
|
||||
}
|
||||
Command::ColWidthsChanged(widths) => cache.col_widths = widths.clone(),
|
||||
Command::ViewportInvalidated => {
|
||||
// Drop the contents but keep the frame: labels arrive next, and
|
||||
// rendering a stale grid under fresh labels is worse than blank.
|
||||
for row in &mut cache.cells {
|
||||
for cell in row {
|
||||
*cell = CellDisplay::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Panel, ViewCursor};
|
||||
|
||||
fn fold(cmds: &[Command]) -> (ViewState, RenderCache) {
|
||||
let mut view = ViewState::default();
|
||||
let mut cache = RenderCache::default();
|
||||
for cmd in cmds {
|
||||
reduce_view(&mut view, &mut cache, cmd);
|
||||
}
|
||||
(view, cache)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_commands_land_in_the_active_views_entry() {
|
||||
let (view, _) = fold(&[
|
||||
Command::SwitchView("Second".into()),
|
||||
Command::SetSelected { row: 3, col: 4 },
|
||||
Command::SetRowOffset(2),
|
||||
Command::SetColOffset(1),
|
||||
]);
|
||||
assert_eq!(
|
||||
view.cursors.get("Second").copied(),
|
||||
Some(ViewCursor {
|
||||
selected: (3, 4),
|
||||
row_offset: 2,
|
||||
col_offset: 1,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!view.cursors.contains_key("Default"),
|
||||
"the view we switched away from never gained an entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// The point of keying the cursor map: switching back restores where
|
||||
/// you were, and the client can do that without a `Workbook`.
|
||||
#[test]
|
||||
fn switching_views_restores_each_cursor() {
|
||||
let (view, _) = fold(&[
|
||||
Command::SetSelected { row: 1, col: 1 },
|
||||
Command::SwitchView("Second".into()),
|
||||
Command::SetSelected { row: 9, col: 9 },
|
||||
Command::SwitchView("Default".into()),
|
||||
]);
|
||||
assert_eq!(view.cursor().selected, (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_commands_address_the_named_panel_only() {
|
||||
let (view, _) = fold(&[
|
||||
Command::SetPanelOpen {
|
||||
panel: Panel::Category,
|
||||
open: true,
|
||||
},
|
||||
Command::SetPanelCursor {
|
||||
panel: Panel::Category,
|
||||
cursor: 4,
|
||||
},
|
||||
]);
|
||||
assert!(view.category_panel_open);
|
||||
assert_eq!(view.cat_panel_cursor, 4);
|
||||
assert!(!view.formula_panel_open && !view.view_panel_open);
|
||||
assert_eq!(view.formula_cursor, 0);
|
||||
assert_eq!(view.view_panel_cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_cat_expand_is_involutive() {
|
||||
let (view, _) = fold(&[
|
||||
Command::ToggleCatExpand("Region".into()),
|
||||
Command::ToggleCatExpand("Region".into()),
|
||||
]);
|
||||
assert!(view.expanded_cats.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_pages_wrap_in_both_directions_and_set_clamps() {
|
||||
let last = HELP_PAGE_COUNT - 1;
|
||||
let (back, _) = fold(&[Command::HelpPage(HelpPage::Prev)]);
|
||||
assert_eq!(back.help_page, last, "page 0 wraps backwards to the last");
|
||||
|
||||
let forward: Vec<Command> = (0..HELP_PAGE_COUNT)
|
||||
.map(|_| Command::HelpPage(HelpPage::Next))
|
||||
.collect();
|
||||
let (wrapped, _) = fold(&forward);
|
||||
assert_eq!(wrapped.help_page, 0, "a full lap returns to the first");
|
||||
|
||||
let (clamped, _) = fold(&[Command::HelpPage(HelpPage::Set(99))]);
|
||||
assert_eq!(clamped.help_page, last, "out of range clamps, never panics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffers_are_set_and_cleared_by_name() {
|
||||
let (view, _) = fold(&[
|
||||
Command::SetBuffer {
|
||||
name: "edit".into(),
|
||||
value: "hi".into(),
|
||||
},
|
||||
Command::SetBuffer {
|
||||
name: "command".into(),
|
||||
value: "w".into(),
|
||||
},
|
||||
Command::ClearBuffer("edit".into()),
|
||||
]);
|
||||
assert_eq!(view.buffers.get("command").map(String::as_str), Some("w"));
|
||||
assert!(!view.buffers.contains_key("edit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_changes_resize_the_cell_grid() {
|
||||
let (_, cache) = fold(&[
|
||||
Command::RowLabelsChanged(vec!["r0".into(), "r1".into()]),
|
||||
Command::ColLabelsChanged(vec!["c0".into(), "c1".into(), "c2".into()]),
|
||||
]);
|
||||
assert_eq!(cache.cells.len(), 2);
|
||||
assert!(cache.cells.iter().all(|r| r.len() == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_updated_writes_by_viewport_position() {
|
||||
let (_, cache) = fold(&[
|
||||
Command::RowLabelsChanged(vec!["r0".into(), "r1".into()]),
|
||||
Command::ColLabelsChanged(vec!["c0".into(), "c1".into()]),
|
||||
Command::CellsUpdated(vec![((1, 0), CellDisplay::number("42"))]),
|
||||
]);
|
||||
assert_eq!(cache.cell(1, 0).unwrap().text, "42");
|
||||
assert!(cache.cell(1, 0).unwrap().numeric);
|
||||
assert_eq!(cache.cell(0, 0).unwrap().text, "");
|
||||
}
|
||||
|
||||
/// A projection that names a cell outside the current viewport must be
|
||||
/// dropped, not panic: labels and cell updates race on the wire, and a
|
||||
/// client that crashes on a stale index is a client that crashes.
|
||||
#[test]
|
||||
fn cells_updated_ignores_positions_outside_the_cache() {
|
||||
let (_, cache) = fold(&[
|
||||
Command::RowLabelsChanged(vec!["r0".into()]),
|
||||
Command::ColLabelsChanged(vec!["c0".into()]),
|
||||
Command::CellsUpdated(vec![((99, 99), CellDisplay::text("ghost"))]),
|
||||
]);
|
||||
assert_eq!(cache.cell(0, 0).unwrap().text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_invalidated_blanks_contents_but_keeps_the_frame() {
|
||||
let (_, cache) = fold(&[
|
||||
Command::RowLabelsChanged(vec!["r0".into()]),
|
||||
Command::ColLabelsChanged(vec!["c0".into()]),
|
||||
Command::CellsUpdated(vec![((0, 0), CellDisplay::number("7"))]),
|
||||
Command::ViewportInvalidated,
|
||||
]);
|
||||
assert_eq!(cache.cell(0, 0).unwrap().text, "");
|
||||
assert_eq!(cache.row_labels, vec!["r0".to_string()]);
|
||||
}
|
||||
|
||||
/// Both ends fold the same commands, so the same stream must produce
|
||||
/// the same state — otherwise the server's mirror drifts from the
|
||||
/// client it is mirroring (improvise-m91).
|
||||
#[test]
|
||||
fn folding_the_same_stream_twice_gives_the_same_state() {
|
||||
let stream = [
|
||||
Command::SwitchView("Second".into()),
|
||||
Command::SetSelected { row: 2, col: 2 },
|
||||
Command::ToggleCatExpand("Region".into()),
|
||||
Command::SetSearchQuery("north".into()),
|
||||
Command::HelpPage(HelpPage::Next),
|
||||
Command::RowLabelsChanged(vec!["r".into()]),
|
||||
Command::ColLabelsChanged(vec!["c".into()]),
|
||||
Command::CellsUpdated(vec![((0, 0), CellDisplay::number("1"))]),
|
||||
];
|
||||
let (a_view, a_cache) = fold(&stream);
|
||||
let (b_view, b_cache) = fold(&stream);
|
||||
assert_eq!(format!("{a_view:?}"), format!("{b_view:?}"));
|
||||
assert_eq!(a_cache, b_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_round_trips_through_json() {
|
||||
let stream = [
|
||||
Command::SetSelected { row: 1, col: 2 },
|
||||
Command::SetRowOffset(3),
|
||||
Command::SetColOffset(4),
|
||||
Command::ChangeMode(AppMode::item_add("Region".into())),
|
||||
Command::SetStatus("saved".into()),
|
||||
Command::SetYanked(Some(CellValue::Number(1.5))),
|
||||
Command::SetSearchQuery("q".into()),
|
||||
Command::SetSearchMode(true),
|
||||
Command::SetBuffer {
|
||||
name: "edit".into(),
|
||||
value: "v".into(),
|
||||
},
|
||||
Command::ClearBuffer("edit".into()),
|
||||
Command::SetTileCatIdx(2),
|
||||
Command::SetPanelOpen {
|
||||
panel: Panel::View,
|
||||
open: true,
|
||||
},
|
||||
Command::SetPanelCursor {
|
||||
panel: Panel::Formula,
|
||||
cursor: 1,
|
||||
},
|
||||
Command::ToggleCatExpand("Region".into()),
|
||||
Command::HelpPage(HelpPage::Set(2)),
|
||||
Command::SetTransientPrefix(Some((KeyCode::Char('g'), KeyModifiers::CONTROL))),
|
||||
Command::SwitchView("Second".into()),
|
||||
Command::CellsUpdated(vec![((0, 1), CellDisplay::error("#div/0"))]),
|
||||
Command::RowLabelsChanged(vec!["r".into()]),
|
||||
Command::ColLabelsChanged(vec!["c".into()]),
|
||||
Command::ColWidthsChanged(vec![8, 12]),
|
||||
Command::ViewportInvalidated,
|
||||
];
|
||||
for cmd in &stream {
|
||||
let json = serde_json::to_string(cmd).expect("serializes");
|
||||
let back: Command = serde_json::from_str(&json).expect("deserializes");
|
||||
assert_eq!(&back, cmd, "round trip changed {cmd:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! What a client needs in order to draw, without the model.
|
||||
//!
|
||||
//! A client holds no `Model`: it is sent *display* values, already
|
||||
//! formatted, and never recomputes a formula. `RenderCache` is that local
|
||||
//! copy of the visible grid, rebuilt by folding projection commands
|
||||
//! (improvise-cqq). It is serialized only to ride in the bootstrap
|
||||
//! [`Snapshot`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ViewState;
|
||||
|
||||
/// One cell as the client draws it: the text, plus the little that changes
|
||||
/// how it is drawn. Deliberately not a `CellValue` — by the time a cell
|
||||
/// reaches a client the number formatting has already happened, and
|
||||
/// re-deriving it client-side is how the two ends drift apart.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CellDisplay {
|
||||
pub text: String,
|
||||
/// Right-align numerics, left-align text — decided by the server, which
|
||||
/// is the side that knows which it was.
|
||||
pub numeric: bool,
|
||||
/// A formula evaluation error, rendered distinctly.
|
||||
pub error: bool,
|
||||
}
|
||||
|
||||
impl CellDisplay {
|
||||
pub fn text(s: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: s.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(s: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: s.into(),
|
||||
numeric: true,
|
||||
error: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(s: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: s.into(),
|
||||
numeric: false,
|
||||
error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's copy of the visible grid.
|
||||
///
|
||||
/// Indexed by position within the *viewport*, not by `CellKey`: the client
|
||||
/// does not know the model's coordinates, and the server is free to change
|
||||
/// what a row means as long as it says so.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RenderCache {
|
||||
pub row_labels: Vec<String>,
|
||||
pub col_labels: Vec<String>,
|
||||
pub col_widths: Vec<u16>,
|
||||
/// Row-major, `row_labels.len()` rows of `col_labels.len()` cells.
|
||||
pub cells: Vec<Vec<CellDisplay>>,
|
||||
}
|
||||
|
||||
impl RenderCache {
|
||||
pub fn cell(&self, row: usize, col: usize) -> Option<&CellDisplay> {
|
||||
self.cells.get(row)?.get(col)
|
||||
}
|
||||
|
||||
/// Resize to match the current labels, preserving what still fits.
|
||||
/// Called after any command that changes the grid's shape.
|
||||
pub fn fit_to_labels(&mut self) {
|
||||
let cols = self.col_labels.len();
|
||||
self.cells.resize_with(self.row_labels.len(), Vec::new);
|
||||
for row in &mut self.cells {
|
||||
row.resize_with(cols, CellDisplay::default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client receives on connect: a value to fold later commands onto.
|
||||
///
|
||||
/// The *only* point at which `ViewState` is serialized (improvise-q08).
|
||||
/// Steady-state traffic after this is commands only.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub view: ViewState,
|
||||
pub cache: RenderCache,
|
||||
}
|
||||
@@ -522,3 +522,29 @@ decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait
|
||||
work_done('improvise-i34',feature,'Storage gained list/delete, implemented for PhysicalFs and MemoryFs with a shared storage_contract test both satisfy. OpfsFs added behind cfg(target_arch=wasm32) using web-sys sync access handles, with open/attach/refresh/commit_deletes as the async seam. Compile- and clippy-clean for wasm32.').
|
||||
work_followup('improvise-i34','browser_verification','OpfsFs is compile-checked only -- no browser in this environment. storage_contract in storage.rs is the sequence it must satisfy. User will verify at the web MVP.').
|
||||
doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to').
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% improvise-cqi: the improvise-protocol crate.
|
||||
% ---------------------------------------------------------------------------
|
||||
crate(improvise_protocol,'crates/improvise-protocol','wire vocabulary + portable session slice: Command, ViewState, RenderCache, reduce_view').
|
||||
layer(protocol,improvise_protocol).
|
||||
crate_depends_on(improvise_protocol,improvise_core).
|
||||
crate_depends_on(improvise_command,improvise_protocol).
|
||||
forbidden(improvise_protocol,ui).
|
||||
forbidden(improvise_protocol,io).
|
||||
forbidden(improvise_protocol,commands).
|
||||
wasm_bundle_crate(improvise_protocol).
|
||||
|
||||
% What travels, and what merely folds. Confusing these inverts the design.
|
||||
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
||||
wire_carries(snapshot,bootstrap_only,'the ONE point ViewState is serialized: a value for a fresh client to fold later commands onto').
|
||||
derived_by_fold(view_state,command_stream,'both ends run reduce_view over the same commands, so the server mirror cannot drift from the client').
|
||||
derived_by_fold(render_cache,projection_commands,'local to each client; serialized only to ride in the bootstrap Snapshot').
|
||||
|
||||
decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.').
|
||||
decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.').
|
||||
kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of "which view is active". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.').
|
||||
work_done('improvise-cqi',feature,'crates/improvise-protocol created. ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig, MinibufColor, HELP_PAGE_COUNT and the key types moved there; improvise-command re-exports them so no caller path changed. Command + reduce_view + RenderCache/CellDisplay/Snapshot added with 15 tests. Added to the CI wasm job.').
|
||||
work_unblocked('improvise-cqi','improvise-gxi','reduce_full can now live in improvise-command taking a protocol Command, with no cycle').
|
||||
doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against').
|
||||
|
||||
Reference in New Issue
Block a user