Files
improvise/crates/improvise-protocol/src/reduce.rs
T

692 lines
26 KiB
Rust

//! The wire vocabulary, and the fold that applies it to a client's view.
//! **Derived, not invented** — `context/code/protocol.md` §2.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use improvise_core::model::cell::CellValue;
use improvise_core::view::Axis;
use crate::basis::Basis;
use crate::render::{Projection, RecordCell};
use crate::resolve::OpenedEditor;
use crate::{AppMode, FormulaId, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
use improvise_core::model::record::RecordHash;
/// 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),
/// The grid this cursor indexes has been renumbered under it. **An
/// invalidation, never a position** — see §2 (improvise-gvnb).
ResetCursor,
ChangeMode(AppMode),
SetStatus(String),
SetYanked(Option<CellValue>),
SetSearchQuery(String),
SetSearchMode(bool),
/// Write a named buffer. Clearing one is `value: ""` — there is no
/// `ClearBuffer`, because there is no such effect (improvise-m0sx, §2).
SetBuffer {
name: String,
value: String,
},
SetTileCatIdx(usize),
/// What a cell editor captured as it opened, or `None` for one opened
/// with nothing to compare against (improvise-ixxx).
SetOpenedEditor(Option<OpenedEditor>),
SetPanelOpen {
panel: Panel,
open: bool,
},
SetPanelCursor {
panel: Panel,
cursor: usize,
},
ToggleCatExpand(String),
HelpPage(HelpPage),
/// Arm a prefix key, or clear the armed one. The *address* of the
/// prefix, never the sub-keymap it names (improvise-jc8).
SetTransientPrefix(Option<(KeyCode, KeyModifiers)>),
/// The active view changed; the name is the cursor map's key. The one
/// command with a foot in both halves — see §2 (improvise-kmgs).
SwitchView(String),
/// Step this client's own view history. The stack is in [`ViewState`], so
/// it is the client's — the model is shared and the UI is not (§2).
ViewBack,
ViewForward,
/// This view is gone; drop it from the history. The **document** half of a
/// client-local stack: only the server can observe a view vanishing (§2).
ForgetView(String),
// ── client → server: document intent ────────────────────────────
/// One mutation of the document, under the basis it claims (§2).
Model {
basis: Basis,
cmd: ModelCommand,
},
// ── server → client: projections ────────────────────────────────
/// One widget's update; the payload names the widget (improvise-rkeq).
Project(Projection),
}
/// A mutation of the shared document: 25 variants, derived not invented (§2). A
/// view-configuration variant **names its view** (improvise-lltt).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ModelCommand {
// ── ModelChange::CellData ───────────────────────────────────────
/// Write one stored cell: a [`RecordCell`] and **nothing else** (ADR 002).
SetCell {
cell: RecordCell,
value: CellValue,
},
ClearCell(RecordCell),
AddItem {
category: String,
item: String,
},
AddItemInGroup {
category: String,
item: String,
group: String,
},
RemoveItem {
category: String,
item: String,
},
AddFormula {
raw: String,
target_category: String,
},
/// Remove the formula `id` names; refused if it is already gone.
RemoveFormula {
id: FormulaId,
},
/// Edit the formula `id` names in place, if it still reads `was` — an edit
/// of that formula, not a remove-then-add (improvise-km71).
ReplaceFormula {
id: FormulaId,
was: String,
raw: String,
},
CleanEmptyRecords,
SortData,
// ── ModelChange::LayoutGeometry ─────────────────────────────────
AddCategory(String),
RemoveCategory(String),
SetAxis {
view: String,
category: String,
axis: Axis,
},
CycleAxis {
view: String,
category: String,
},
TransposeAxes {
view: String,
},
SetPageSelection {
view: String,
category: String,
item: String,
},
HideItem {
view: String,
category: String,
item: String,
},
ShowItem {
view: String,
category: String,
item: String,
},
ToggleGroup {
view: String,
category: String,
group: String,
},
TogglePruneEmpty {
view: String,
},
SetNumberFormat {
view: String,
format: String,
},
CreateView(String),
DeleteView(String),
// ── ModelChange::WholeWorkbook ──────────────────────────────────
/// Paths name documents in the host's `Storage`, never the client's (gotcha 2).
LoadModel(PathBuf),
ImportJsonHeadless {
path: PathBuf,
model_name: Option<String>,
array_path: Option<String>,
},
}
/// Which records a [`ModelCommand`] writes. The **one** home: the server
/// checks against it and a client claims by it — `context/code/protocol.md` §2.
pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
match cmd {
ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => vec![cell.record],
ModelCommand::AddItem { .. }
| ModelCommand::AddItemInGroup { .. }
| ModelCommand::RemoveItem { .. }
| ModelCommand::AddFormula { .. }
| ModelCommand::RemoveFormula { .. }
| ModelCommand::ReplaceFormula { .. }
| ModelCommand::CleanEmptyRecords
| ModelCommand::SortData
| ModelCommand::AddCategory(_)
| ModelCommand::RemoveCategory(_)
| ModelCommand::SetAxis { .. }
| ModelCommand::CycleAxis { .. }
| ModelCommand::TransposeAxes { .. }
| ModelCommand::SetPageSelection { .. }
| ModelCommand::HideItem { .. }
| ModelCommand::ShowItem { .. }
| ModelCommand::ToggleGroup { .. }
| ModelCommand::TogglePruneEmpty { .. }
| ModelCommand::SetNumberFormat { .. }
| ModelCommand::CreateView(_)
| ModelCommand::DeleteView(_)
| ModelCommand::LoadModel(_)
| ModelCommand::ImportJsonHeadless { .. } => Vec::new(),
}
}
/// 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),
}
/// Which stack a step pops, and which it pushes onto. An enum rather than a
/// `bool`, so neither call site can read the other's direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Rewind {
Back,
Forward,
}
/// The mirror of `effect::SwitchView`: a move records where it came from, and
/// forgets any forward history — `context/code/protocol.md` §2.
fn switched(view: &mut ViewState, name: &str) {
let leaving = view.active_view.clone();
if leaving != name {
view.view_back_stack.push(crate::ViewFrame {
view_name: leaving,
mode: view.mode.clone(),
});
view.view_forward_stack.clear();
}
view.active_view = name.to_string();
}
/// The mirror of `effect::ViewBack` / `effect::ViewForward`: pop one stack,
/// push the view being left onto the other, and restore its mode.
fn stepped(view: &mut ViewState, rewind: Rewind) {
let popped = match rewind {
Rewind::Back => view.view_back_stack.pop(),
Rewind::Forward => view.view_forward_stack.pop(),
};
let Some(frame) = popped else {
return;
};
let leaving = crate::ViewFrame {
view_name: view.active_view.clone(),
mode: view.mode.clone(),
};
match rewind {
Rewind::Back => view.view_forward_stack.push(leaving),
Rewind::Forward => view.view_back_stack.push(leaving),
}
view.active_view = frame.view_name;
view.mode = frame.mode;
}
/// Apply one command to a client's view state. The **same function runs on
/// both ends**, so the mirror cannot drift (improvise-cqq, improvise-m91).
pub fn reduce_view(view: &mut ViewState, 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::ResetCursor => *view.cursor_mut() = crate::ViewCursor::default(),
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::SetTileCatIdx(i) => view.tile_cat_idx = *i,
Command::SetOpenedEditor(opened) => view.opened_editor = opened.clone(),
Command::SetPanelOpen { panel, open } => *view.panel_open_mut(*panel) = *open,
Command::SetPanelCursor { panel, cursor } => *view.panel_cursor_mut(*panel) = *cursor,
Command::ToggleCatExpand(cat) => {
if !view.expanded_cats.remove(cat) {
view.expanded_cats.insert(cat.clone());
}
}
// Clamped, not wrapped: these mirror `HelpPageNext`/`HelpPagePrev`,
// which saturate. The two used to disagree — `context/code/protocol.md`.
Command::HelpPage(step) => {
view.help_page = match step {
HelpPage::Next => (view.help_page + 1).min(HELP_PAGE_COUNT - 1),
HelpPage::Prev => view.help_page.saturating_sub(1),
HelpPage::Set(n) => (*n).min(HELP_PAGE_COUNT - 1),
};
}
Command::SetTransientPrefix(p) => view.transient_prefix = *p,
Command::SwitchView(name) => switched(view, name),
Command::ViewBack => stepped(view, Rewind::Back),
Command::ViewForward => stepped(view, Rewind::Forward),
Command::ForgetView(name) => view.forget_view(name),
// Neither is the view slice's business: a projection goes to one
// widget's cache, a model command comes back as projections.
Command::Model { .. } | Command::Project(_) => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{CategoryTreeUpdate, CellDisplay, GridUpdate, TileEntry, ViewSummary};
use crate::{AxisFrame, Panel, ViewCursor};
use improvise_core::model::cell::CellKey;
use improvise_core::model::record::RecordHash;
use improvise_core::view::Axis;
/// A model command under a basis, composed the only way there is: from
/// what the server published. See `context/test-evidence/protocol.md`.
fn modelled(cmd: ModelCommand) -> Command {
let mut cache = crate::BasisCache::default();
crate::WidgetCache::apply(
&mut cache,
&crate::basis::published(0, 0, Default::default()),
);
Command::Model {
basis: cache.claim([]).expect("seeded"),
cmd,
}
}
fn fold(cmds: &[Command]) -> ViewState {
let mut view = ViewState::default();
for cmd in cmds {
reduce_view(&mut view, cmd);
}
view
}
#[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 toggle_cat_expand_is_involutive() {
let view = fold(&[
Command::ToggleCatExpand("Region".into()),
Command::ToggleCatExpand("Region".into()),
]);
assert!(view.expanded_cats.is_empty());
}
/// Every arm stops at a bound rather than wrapping, because the effects
/// these were derived from do. See `context/test-evidence/protocol.md`.
#[test]
fn help_pages_stop_at_both_ends_and_set_clamps() {
let last = HELP_PAGE_COUNT - 1;
let back = fold(&[Command::HelpPage(HelpPage::Prev)]);
assert_eq!(back.help_page, 0, "page 0 stays put going backwards");
let forward: Vec<Command> = (0..HELP_PAGE_COUNT + 1)
.map(|_| Command::HelpPage(HelpPage::Next))
.collect();
let walked = fold(&forward);
assert_eq!(walked.help_page, last, "walking off the end stops there");
let clamped = fold(&[Command::HelpPage(HelpPage::Set(99))]);
assert_eq!(clamped.help_page, last, "out of range clamps, never panics");
}
/// Clearing leaves the key **present and empty**, which is what both
/// implementations do. `context/test-evidence/protocol.md`.
#[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::SetBuffer {
name: "edit".into(),
value: String::new(),
},
]);
assert_eq!(view.buffers.get("command").map(String::as_str), Some("w"));
assert_eq!(view.buffers.get("edit").map(String::as_str), Some(""));
}
/// Both ends fold the same commands (improvise-m91); see
/// `context/test-evidence/protocol.md`.
#[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::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
"r",
])))),
];
let a = fold(&stream);
let b = fold(&stream);
assert_eq!(format!("{a:?}"), format!("{b:?}"));
}
/// A model command is the document's business, not this client's. See
/// `context/test-evidence/protocol.md`.
#[test]
fn a_model_command_leaves_the_view_slice_untouched() {
let mut view = ViewState::default();
view.cursor_mut().selected = (3, 4);
let before = format!("{view:?}");
reduce_view(
&mut view,
&modelled(ModelCommand::AddCategory("Product".into())),
);
reduce_view(
&mut view,
&modelled(ModelCommand::TransposeAxes {
view: "Default".into(),
}),
);
assert_eq!(before, format!("{view:?}"));
}
#[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::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()),
// Every arm of the envelope, each carrying a payload
// (doctrine populate_before_roundtrip).
Command::Project(Projection::Grid(GridUpdate::Cells(vec![
((0, 1), CellDisplay::number("1,234.50")),
((2, 3), CellDisplay::error("#div/0")),
]))),
Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
"c0",
])))),
Command::Project(Projection::Grid(GridUpdate::ColWidths(vec![8, 12]))),
Command::Project(Projection::CategoryTree(CategoryTreeUpdate::Items {
category: "Region".into(),
items: vec!["North".into(), "South".into()],
})),
Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])),
Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
}])),
Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
kind: crate::CategoryKind::Regular,
axis: Axis::Page,
}])),
// One populated value per payload family: `RecordCell`, `Axis`,
// `PathBuf`. The exhaustive 26-variant walk is in the host crate.
modelled(ModelCommand::SetCell {
cell: RecordCell {
record: RecordHash::of(&CellKey::new(vec![("Region".into(), "East".into())])),
measure: Some("Revenue".into()),
},
value: CellValue::Text("pending".into()),
}),
modelled(ModelCommand::SetAxis {
view: "Default".into(),
category: "Region".into(),
axis: Axis::Filter,
}),
modelled(ModelCommand::AddItemInGroup {
category: "Region".into(),
item: "Coastal_East".into(),
group: "Coastal".into(),
}),
modelled(ModelCommand::AddCategory("Product".into())),
modelled(ModelCommand::ImportJsonHeadless {
path: PathBuf::from("rows.json"),
model_name: Some("Rows".into()),
array_path: None,
}),
modelled(ModelCommand::TransposeAxes {
view: "Default".into(),
}),
];
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:?}");
}
}
/// improvise-56ek: `Panel` becomes a field in one place, and the fold reaches it.
/// Replaces a `Panel::Category`-only test that could not tell panels apart.
#[test]
fn a_panel_command_writes_that_panel_and_no_other() {
use crate::Panel;
for panel in Panel::ALL {
// Zeroed first, so opening is a move for every panel: the category
// panel is OPEN at launch, and asserting it is open passes a no-op.
let mut view = ViewState::default();
for p in Panel::ALL {
*view.panel_open_mut(p) = false;
*view.panel_cursor_mut(p) = 0;
}
reduce_view(&mut view, &Command::SetPanelOpen { panel, open: true });
reduce_view(&mut view, &Command::SetPanelCursor { panel, cursor: 7 });
for other in Panel::ALL {
assert_eq!(
view.panel_open(other),
other == panel,
"SetPanelOpen({panel:?}) wrote {other:?}'s open flag"
);
assert_eq!(
view.panel_cursor(other),
if other == panel { 7 } else { 0 },
"SetPanelCursor({panel:?}) wrote {other:?}'s cursor"
);
}
// By name, once per panel: asserting only through the accessors asserts
// through the mapping under test, and a bijection passes (gotcha 3).
let (open, cursor) = match panel {
Panel::Formula => (view.formula_panel_open, view.formula_cursor),
Panel::Category => (view.category_panel_open, view.cat_panel_cursor),
Panel::View => (view.view_panel_open, view.view_panel_cursor),
};
assert!(open, "{panel:?} is not the field {panel:?} names");
assert_eq!(cursor, 7, "{panel:?}'s cursor is not the field it names");
}
}
/// improvise-hkbc: every field of every `ModelCommand` names its target by
/// name, by path or by surrogate. A row or column index fails to COMPILE.
fn position_free(cmd: &ModelCommand) -> usize {
fn name(_: &str) -> usize {
1
}
fn surrogate(_: &RecordHash) -> usize {
1
}
fn formula(_: &FormulaId) -> usize {
1
}
fn record(RecordCell { record, measure }: &RecordCell) -> usize {
surrogate(record) + optional_name(measure)
}
fn path(_: &std::path::Path) -> usize {
1
}
fn value(_: &CellValue) -> usize {
1
}
fn axis(_: &Axis) -> usize {
1
}
fn optional_name(_: &Option<String>) -> usize {
1
}
// No `..` anywhere: a field added to a variant must be classified here.
match cmd {
ModelCommand::SetCell { cell, value: v } => record(cell) + value(v),
ModelCommand::ClearCell(cell) => record(cell),
ModelCommand::AddItem { category, item }
| ModelCommand::RemoveItem { category, item } => name(category) + name(item),
ModelCommand::AddItemInGroup {
category,
item,
group,
} => name(category) + name(item) + name(group),
ModelCommand::AddFormula {
raw,
target_category,
} => name(raw) + name(target_category),
ModelCommand::RemoveFormula { id } => formula(id),
ModelCommand::ReplaceFormula { id, was, raw } => formula(id) + name(was) + name(raw),
ModelCommand::CleanEmptyRecords | ModelCommand::SortData => 0,
ModelCommand::AddCategory(category)
| ModelCommand::RemoveCategory(category)
| ModelCommand::CreateView(category)
| ModelCommand::DeleteView(category) => name(category),
ModelCommand::SetAxis {
view,
category,
axis: a,
} => name(view) + name(category) + axis(a),
ModelCommand::CycleAxis { view, category } => name(view) + name(category),
ModelCommand::TransposeAxes { view } | ModelCommand::TogglePruneEmpty { view } => {
name(view)
}
ModelCommand::SetPageSelection {
view,
category,
item,
}
| ModelCommand::HideItem {
view,
category,
item,
}
| ModelCommand::ShowItem {
view,
category,
item,
} => name(view) + name(category) + name(item),
ModelCommand::ToggleGroup {
view,
category,
group,
} => name(view) + name(category) + name(group),
ModelCommand::SetNumberFormat { view, format } => name(view) + name(format),
ModelCommand::LoadModel(p) => path(p),
ModelCommand::ImportJsonHeadless {
path: p,
model_name,
array_path,
} => path(p) + optional_name(model_name) + optional_name(array_path),
}
}
/// The guard is the signature above; this only keeps it compiled and reached.
#[test]
fn every_model_command_field_is_position_free() {
assert_eq!(
position_free(&ModelCommand::TransposeAxes { view: "V".into() }),
1
);
}
}