Files
improvise/crates/improvise-protocol/src/lib.rs
T
Edward Langley d1ce2ecbc3 fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.

rmvx. `the_command_names_that_still_need_the_host_are_these` was built with
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
were outside its reach entirely and could never have failed it. A named list
that silently scopes itself to one mode is a guard that looks broader than it
is. `every_advertised_command_answers_in_its_own_mode` crosses
`help::advertised` — command NAMES, so no hint string is tokenised — against
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
only mean an unhandled name. It found a seventh instance the ticket had not
predicted: `view-back` is advertised in RecordsNormal.

Six names now decline out loud, each with its own true subject, out of one
`declined` table; `stranding` returns the subject rather than a bool, so `:`
says the command line is unavailable instead of doing nothing. A declining step
takes its whole `Binding::Sequence` with it, which is what keeps `o` ("add a
record row, then edit it") from opening an editor on a row nobody created.

fdt6. `commit-buffer formula` already resolved; what did not exist was any way
to SEE the typing. `RenderModel::editing` is scoped to the two cell-editing
modes and reads the "edit" buffer by name, so FormulaEdit, CategoryAdd, ItemAdd
and the three prompts opened and swallowed every character. `RenderModel::
minibuffer` is `AppMode::minibuffer()` plus the buffer that config names, read
through `improvise_protocol::read_buffer` so gotcha 11 keeps its one home, and
`web/app.js` paints it.

gcvb. The records-mode early return was broader than its reason. A
`CellId::Create` commits the typed NAME as one existing `ModelCommand`, reads
no `record_col` and writes no cell, so the return narrows rather than
disappears. `+cat` must NOT advance: an inserted column renumbers every index
to its right and the advance was computed from the pre-commit layout, so a
client that advanced anyway would land one column off, silently. Both halves
proved by injection against `CommitAndAdvance` in `tests/lockstep.rs`.

Every new guard was proved by injecting the fault it names. The one worth
repeating: `web/tests/render_surface.test.js` went green over a page that
painted nothing, because the string `model.minibuffer` was inside a function
nothing called. The static check is a completeness check, never an execution
check — `harness/tests/keys.spec.js` caught it in Chromium on the first run.

1394 → 1397 Rust tests; harness 17 → 19 passed, 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:36:03 -07:00

433 lines
15 KiB
Rust

//! Portable session state ([`ViewState`]), the wire vocabulary ([`Command`])
//! and the framing ([`frame`]). See `context/code/protocol.md`.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod basis;
pub mod client;
pub mod frame;
pub mod help;
pub mod key;
pub mod keymap;
mod reduce;
pub mod render;
pub mod resolve;
pub mod viewmodel;
pub use basis::{Basis, BasisCache, Rejection};
pub use client::ClientState;
pub use frame::{ClientFrame, ServerFrame};
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
pub use key::{KeyCode, KeyEvent, KeyModifiers};
pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
pub use reduce::{Command, HelpPage, ModelCommand, records_named, reduce_view};
pub use render::{
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate,
MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry,
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
WizardProposal, WizardStepKind, header_level_widths,
};
pub use resolve::{Click, KeyOutcome, click, read_buffer, resolve, typed_cell_command};
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
// `improvise-core`'s, because the index that resolves one lives in
// `DataStore`. Re-exported so a client depends on the protocol alone.
pub use improvise_core::model::record::{AggregateHash, RecordHash};
pub use improvise_core::view::layout::PageAxis;
// On the wire in `ModelCommand::SetCell`, so a client needs to name it
// without depending on `improvise-core` directly.
pub 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.
pub const HELP_PAGE_COUNT: usize = 5;
/// Where one client is looking within one view. Per-client (improvise-8uz);
/// `View` no longer carries it — `context/code/protocol.md` §1.
#[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 terminal
/// mapping lives in `draw.rs` (gotcha 26).
#[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 panel command args are generated rather than spelled out.
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 only — `context/code/protocol.md` §1.
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,
},
/// Ask for a path to write the *model* to. The sibling of `ExportPrompt`,
/// which writes CSV; this one commits to `SaveAs` (improvise-lae).
SavePrompt {
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 {
/// The mode a keymap's `enter-mode <name>` argument names. **Not every
/// variant is nameable** — `context/code/protocol.md` §1 says which and why.
pub fn from_name(s: &str) -> Option<Self> {
match s {
"normal" => Some(Self::Normal),
"help" => Some(Self::Help),
"formula-panel" => Some(Self::FormulaPanel),
"category-panel" => Some(Self::CategoryPanel),
"view-panel" => Some(Self::ViewPanel),
"tile-select" => Some(Self::TileSelect),
"command" => Some(Self::command_mode()),
"category-add" => Some(Self::category_add()),
"editing" => Some(Self::editing()),
"records-normal" => Some(Self::RecordsNormal),
"records-editing" => Some(Self::records_editing()),
"formula-edit" => Some(Self::formula_edit()),
"export-prompt" => Some(Self::export_prompt()),
"save-prompt" => Some(Self::save_prompt()),
_ => None,
}
}
/// The side panel this mode is focused in. A panel's sub-modes count as
/// that panel, so its styling survives the user typing into it.
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::SavePrompt { .. }
| 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, .. }
| Self::SavePrompt { 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,
},
}
}
pub fn save_prompt() -> Self {
Self::SavePrompt {
minibuf: MinibufferConfig {
buffer_key: "save".into(),
prompt: "save as: ".into(),
color: MinibufColor::Yellow,
},
}
}
}
/// The **portable** half of the session — the one thing serialized into the
/// Snapshot. Membership is gated: `context/code/protocol.md` §1.
#[derive(Debug, Clone, PartialEq, 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 (improvise-cqi); see gotcha 29.
pub active_view: String,
/// Cursor and scroll position per view name, so switching views restores
/// where you were in each. Absent key means the origin (improvise-8uz).
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 (g→gg, y→yy, Z→ZZ). The key that opened
/// it, not the sub-keymap it names — gotcha 13, 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: true,
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: the part of the boundary the compiler can hold.
/// See `context/test-evidence/protocol.md`.
#[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::Projection>();
portable::<super::CellDisplay>();
portable::<super::GridCache>();
portable::<super::CategoryTreeCache>();
portable::<super::FormulaCache>();
portable::<super::ViewListCache>();
portable::<super::TileBarCache>();
portable::<super::Snapshot>();
portable::<super::KeyCode>();
portable::<super::KeyModifiers>();
}
}