Files
improvise/crates/improvise-protocol/src/lib.rs
T
Edward Langley bdf9bff472 The editor overlay and Enter read one buffer (improvise-ktlk)
PR #162 (improvise-xg1z) made render.rs paint edit_seed when Editing
arrived without its edit buffer, but commit and append-char still read
the absent buffer as the empty string. So the overlay showed the value,
Enter composed ClearCell, and a typed character replaced the text
instead of extending it: two derivations of what is in the editor.

The fold now seeds the missing buffer (ClientState::settle_editor, next
to settle_mode), and render.rs reads the buffer through read_buffer, as
commit does. edit_seed goes back to pub(crate) and no renderer calls it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:53:51 -07:00

557 lines
20 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;
#[cfg(any(test, feature = "test-support"))]
pub mod variants;
pub mod viewmodel;
#[cfg(test)]
mod vocabulary_is_composed;
pub use basis::{Basis, BasisCache, Rejection};
pub use client::{ClientState, Sent};
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, Request, click, read_buffer, resolve, typed, 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,
},
}
/// `Normal` and `RecordsNormal` are one mode over two layouts, so the mode an
/// end holds must answer for the grid it holds — `context/code/protocol.md`.
pub fn settled_mode(mode: &AppMode, records: bool) -> AppMode {
// The pair and nothing else: `Editing`/`RecordsEditing` are NOT settled,
// which is why a records decision asks the grid and never the mode.
match (mode, records) {
(AppMode::Normal | AppMode::RecordsNormal, true) => AppMode::RecordsNormal,
(AppMode::Normal | AppMode::RecordsNormal, false) => AppMode::Normal,
(other, _) => other.clone(),
}
}
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),
Self::Normal
| Self::FormulaPanel
| Self::CategoryPanel
| Self::ViewPanel
| Self::TileSelect
| Self::ImportWizard
| Self::Help
| Self::Quit
| Self::RecordsNormal => None,
}
}
/// Every variant, one sample each, walked rather than listed. Totality is
/// **tested, not structural** — `context/code/protocol.md` §1, improvise-5ubs.
pub fn all() -> Vec<Self> {
let mut out = vec![Self::Normal];
let mut next = Self::Normal.after();
while !out.contains(&next) {
out.push(next.clone());
next = next.after();
}
out
}
/// The modes that take typed text: the ones carrying a `MinibufferConfig`,
/// derived from [`AppMode::minibuffer`] rather than listed (improvise-g9jb).
pub fn text_entry() -> Vec<Self> {
Self::all()
.into_iter()
.filter(|mode| mode.minibuffer().is_some())
.collect()
}
/// The next variant in the cycle [`AppMode::all`] walks. A new variant must
/// answer here; that it is also POINTED AT is what §1's test checks.
fn after(&self) -> Self {
match self {
Self::Normal => Self::editing(),
Self::Editing { .. } => Self::records_editing(),
Self::RecordsEditing { .. } => Self::RecordsNormal,
Self::RecordsNormal => Self::formula_edit(),
Self::FormulaEdit { .. } => Self::FormulaPanel,
Self::FormulaPanel => Self::CategoryPanel,
Self::CategoryPanel => Self::category_add(),
Self::CategoryAdd { .. } => Self::item_add("Region".into()),
Self::ItemAdd { .. } => Self::ViewPanel,
Self::ViewPanel => Self::TileSelect,
Self::TileSelect => Self::ImportWizard,
Self::ImportWizard => Self::export_prompt(),
Self::ExportPrompt { .. } => Self::save_prompt(),
Self::SavePrompt { .. } => Self::command_mode(),
Self::CommandMode { .. } => Self::Help,
Self::Help => Self::Quit,
Self::Quit => Self::Normal,
}
}
/// 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()
}
/// Drop every history frame naming `view`. A frame is somewhere you can go
/// back to, so a deleted view must leave — `context/code/protocol.md` §2.
pub fn forget_view(&mut self, view: &str) {
self.view_back_stack.retain(|f| f.view_name != view);
self.view_forward_stack.retain(|f| f.view_name != view);
}
/// 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()
}
/// The **one** place a [`Panel`] becomes a field (improvise-56ek). The TUI's
/// `SetPanelOpen` effect and `reduce_view`'s arm both come here.
pub fn panel_open_mut(&mut self, panel: Panel) -> &mut bool {
match panel {
Panel::Formula => &mut self.formula_panel_open,
Panel::Category => &mut self.category_panel_open,
Panel::View => &mut self.view_panel_open,
}
}
/// Read side of [`ViewState::panel_open_mut`]. A caller that matched on the
/// panel itself would be the second derivation this pair exists to remove.
pub fn panel_open(&self, panel: Panel) -> bool {
match panel {
Panel::Formula => self.formula_panel_open,
Panel::Category => self.category_panel_open,
Panel::View => self.view_panel_open,
}
}
/// The same single home for the panel cursors, which carried the identical
/// three-way match in the identical two crates.
pub fn panel_cursor_mut(&mut self, panel: Panel) -> &mut usize {
match panel {
Panel::Formula => &mut self.formula_cursor,
Panel::Category => &mut self.cat_panel_cursor,
Panel::View => &mut self.view_panel_cursor,
}
}
/// Read side of [`ViewState::panel_cursor_mut`].
pub fn panel_cursor(&self, panel: Panel) -> usize {
match panel {
Panel::Formula => self.formula_cursor,
Panel::Category => self.cat_panel_cursor,
Panel::View => self.view_panel_cursor,
}
}
}
#[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>();
}
/// improvise-5ubs: a cyclic `after()` can silently omit a variant nothing
/// points at. Evidence: `context/test-evidence/protocol.md`.
#[test]
fn the_app_mode_cycle_reaches_every_variant_serde_declares() {
crate::variants::cycle_is_total(&super::AppMode::all(), "AppMode");
}
}