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
|
run: cargo check -p improvise-core --target wasm32-unknown-unknown
|
||||||
- name: improvise-formula
|
- name: improvise-formula
|
||||||
run: cargo check -p improvise-formula --target wasm32-unknown-unknown
|
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
|
- name: improvise-io
|
||||||
run: cargo check -p improvise-io --target wasm32-unknown-unknown
|
run: cargo check -p improvise-io --target wasm32-unknown-unknown
|
||||||
- name: improvise-command
|
- name: improvise-command
|
||||||
|
|||||||
Generated
+10
@@ -795,6 +795,7 @@ dependencies = [
|
|||||||
"improvise-core",
|
"improvise-core",
|
||||||
"improvise-formula",
|
"improvise-formula",
|
||||||
"improvise-io",
|
"improvise-io",
|
||||||
|
"improvise-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -849,6 +850,15 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "improvise-protocol"
|
||||||
|
version = "0.1.0-rc2"
|
||||||
|
dependencies = [
|
||||||
|
"improvise-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[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]
|
[package]
|
||||||
name = "improvise"
|
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-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
|
||||||
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
||||||
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
|
- `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".
|
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.
|
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
|
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/`)
|
### Command layer (`crates/improvise-command/src/command/`)
|
||||||
```
|
```
|
||||||
cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
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/`)
|
### Semantic UI state (`crates/improvise-command/src/ui/`)
|
||||||
```
|
```
|
||||||
ui/effect.rs 2568 / 61t Effect trait, 50+ effect types
|
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/cat_tree.rs 156 / 6t Category tree flattener for panel
|
||||||
ui/viewport.rs 128 compute_col_widths / row_header_width / visible_cols (pure geometry)
|
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]
|
[dependencies]
|
||||||
improvise-core = { path = "../improvise-core" }
|
improvise-core = { path = "../improvise-core" }
|
||||||
|
improvise-protocol = { path = "../improvise-protocol" }
|
||||||
improvise-formula = { path = "../improvise-formula" }
|
improvise-formula = { path = "../improvise-formula" }
|
||||||
improvise-io = { path = "../improvise-io" }
|
improvise-io = { path = "../improvise-io" }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
//! and applies them directly.
|
//! and applies them directly.
|
||||||
|
|
||||||
pub mod cmd;
|
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 keymap;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
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 anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
pub use improvise_protocol::{
|
||||||
use std::collections::HashMap;
|
AppMode, HELP_PAGE_COUNT, MinibufColor, MinibufferConfig, Panel, ViewCursor, ViewFrame,
|
||||||
|
ViewState,
|
||||||
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -15,39 +20,12 @@ use crate::command::cmd::CmdContext;
|
|||||||
use crate::command::keymap::{Keymap, KeymapSet};
|
use crate::command::keymap::{Keymap, KeymapSet};
|
||||||
use crate::format::parse_number_format;
|
use crate::format::parse_number_format;
|
||||||
use crate::import::wizard::ImportWizard;
|
use crate::import::wizard::ImportWizard;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::CellKey;
|
||||||
use crate::persistence;
|
use crate::persistence;
|
||||||
use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_visible_cols};
|
use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_visible_cols};
|
||||||
use crate::view::GridLayout;
|
use crate::view::GridLayout;
|
||||||
use crate::workbook::Workbook;
|
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
|
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||||
/// yet been applied to the model.
|
/// yet been applied to the model.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -60,287 +38,6 @@ pub struct DrillState {
|
|||||||
pub pending_edits: std::collections::HashMap<(usize, crate::view::RecordCol), String>,
|
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
|
/// Document state slice: the workbook and its IO bookkeeping. Distinct from
|
||||||
/// `Workbook` itself (which is pure document semantics in `improvise-core`)
|
/// `Workbook` itself (which is pure document semantics in `improvise-core`)
|
||||||
/// because `file_path` and `dirty` are persistence-layer concerns.
|
/// 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
|
/// Top-level TUI application state. Composed of two named slices and a
|
||||||
/// residue of fields that don't belong to either:
|
/// residue of fields that don't belong to either:
|
||||||
///
|
///
|
||||||
@@ -530,6 +147,7 @@ impl App {
|
|||||||
// the caller must gather them explicitly.
|
// the caller must gather them explicitly.
|
||||||
let none_cats = workbook.active_view().none_cats();
|
let none_cats = workbook.active_view().none_cats();
|
||||||
workbook.model.recompute_formulas(&none_cats);
|
workbook.model.recompute_formulas(&none_cats);
|
||||||
|
let active_view_name = workbook.active_view.clone();
|
||||||
let layout = {
|
let layout = {
|
||||||
let view = workbook.active_view();
|
let view = workbook.active_view();
|
||||||
GridLayout::with_frozen_records(&workbook.model, view, None)
|
GridLayout::with_frozen_records(&workbook.model, view, None)
|
||||||
@@ -540,7 +158,10 @@ impl App {
|
|||||||
file_path,
|
file_path,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
},
|
},
|
||||||
view_state: ViewState::default(),
|
view_state: ViewState {
|
||||||
|
active_view: active_view_name,
|
||||||
|
..ViewState::default()
|
||||||
|
},
|
||||||
wizard: None,
|
wizard: None,
|
||||||
drill_state: None,
|
drill_state: None,
|
||||||
last_autosave: Instant::now(),
|
last_autosave: Instant::now(),
|
||||||
@@ -558,7 +179,7 @@ impl App {
|
|||||||
/// This client's cursor in the active view. Absent means the origin, so
|
/// This client's cursor in the active view. Absent means the origin, so
|
||||||
/// a view visited for the first time opens at (0, 0).
|
/// a view visited for the first time opens at (0, 0).
|
||||||
pub fn cursor(&self) -> ViewCursor {
|
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`.
|
/// 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.
|
/// Mutable cursor for the active view, created at the origin if absent.
|
||||||
pub fn cursor_mut(&mut self) -> &mut ViewCursor {
|
pub fn cursor_mut(&mut self) -> &mut ViewCursor {
|
||||||
let name = self.model_state.workbook.active_view.clone();
|
self.view_state.cursor_mut()
|
||||||
self.view_state.cursors.entry(name).or_default()
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Send the active view's cursor back to the origin. Called when the
|
||||||
@@ -792,6 +432,8 @@ impl App {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Every mode the user can be in, so the dispatch-method tests below
|
/// Every mode the user can be in, so the dispatch-method tests below
|
||||||
/// exercise all arms. `Quit` is included even though it never renders.
|
/// 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
|
/// improvise-8uz: moving the cursor must leave the shared document
|
||||||
/// untouched, so that one client scrolling cannot move another's
|
/// untouched, so that one client scrolling cannot move another's
|
||||||
/// viewport. Before the move these three effects wrote into
|
/// viewport. Before the move these three effects wrote into
|
||||||
@@ -1031,18 +710,18 @@ mod tests {
|
|||||||
let mut app = two_col_model();
|
let mut app = two_col_model();
|
||||||
app.model_state.workbook.create_view("Second");
|
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().selected = (2, 1);
|
||||||
app.cursor_mut().row_offset = 1;
|
app.cursor_mut().row_offset = 1;
|
||||||
|
|
||||||
app.model_state.workbook.switch_view("Second").unwrap();
|
app.switch_to_view("Second");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.cursor().selected,
|
app.cursor().selected,
|
||||||
(0, 0),
|
(0, 0),
|
||||||
"a different view starts at its own cursor, not the previous view's"
|
"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().selected, (2, 1));
|
||||||
assert_eq!(app.cursor().row_offset, 1);
|
assert_eq!(app.cursor().row_offset, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ impl Effect for SwitchView {
|
|||||||
});
|
});
|
||||||
app.view_state.view_forward_stack.clear();
|
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 {
|
fn kind(&self) -> EffectKind {
|
||||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||||
@@ -353,7 +353,7 @@ impl Effect for ViewBack {
|
|||||||
view_name: current,
|
view_name: current,
|
||||||
mode: app.view_state.mode.clone(),
|
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;
|
app.view_state.mode = frame.mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,7 +373,7 @@ impl Effect for ViewForward {
|
|||||||
view_name: current,
|
view_name: current,
|
||||||
mode: app.view_state.mode.clone(),
|
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;
|
app.view_state.mode = frame.mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,8 +913,7 @@ impl Effect for AcceptWizardModel {
|
|||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
let built = app.wizard.as_mut().and_then(|w| w.take_built());
|
let built = app.wizard.as_mut().and_then(|w| w.take_built());
|
||||||
if let Some((workbook, warnings)) = built {
|
if let Some((workbook, warnings)) = built {
|
||||||
app.model_state.workbook = workbook;
|
app.install_workbook(workbook);
|
||||||
app.view_state.cursors.clear();
|
|
||||||
app.view_state.formula_cursor = 0;
|
app.view_state.formula_cursor = 0;
|
||||||
app.model_state.dirty = true;
|
app.model_state.dirty = true;
|
||||||
app.view_state.status_msg = if warnings.is_empty() {
|
app.view_state.status_msg = if warnings.is_empty() {
|
||||||
@@ -993,8 +992,7 @@ impl Effect for LoadModel {
|
|||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
match crate::persistence::load_from(&*app.storage, &self.0) {
|
match crate::persistence::load_from(&*app.storage, &self.0) {
|
||||||
Ok(loaded) => {
|
Ok(loaded) => {
|
||||||
app.model_state.workbook = loaded;
|
app.install_workbook(loaded);
|
||||||
app.view_state.cursors.clear();
|
|
||||||
app.view_state.status_msg = format!("Loaded from {}", self.0.display());
|
app.view_state.status_msg = format!("Loaded from {}", self.0.display());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1124,7 +1122,7 @@ impl Effect for ImportJsonHeadless {
|
|||||||
|
|
||||||
match pipeline.build_model_with_warnings() {
|
match pipeline.build_model_with_warnings() {
|
||||||
Ok((new_workbook, build_warnings)) => {
|
Ok((new_workbook, build_warnings)) => {
|
||||||
app.model_state.workbook = new_workbook;
|
app.install_workbook(new_workbook);
|
||||||
warnings.extend(build_warnings);
|
warnings.extend(build_warnings);
|
||||||
app.view_state.status_msg = if warnings.is_empty() {
|
app.view_state.status_msg = if warnings.is_empty() {
|
||||||
"Imported successfully".to_string()
|
"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_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.').
|
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').
|
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