3d374d8b3e
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
456 lines
17 KiB
Rust
456 lines
17 KiB
Rust
//! The browser-side improvise client (improvise-gsw): a fold and a key
|
|
//! classifier, nothing else. See `context/code/clients.md`.
|
|
|
|
pub mod keys;
|
|
pub mod render;
|
|
pub mod socket;
|
|
|
|
pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve};
|
|
pub use keys::BrowserKey;
|
|
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
|
|
pub use socket::EditTarget;
|
|
|
|
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
/// The handle a JS bootstrap holds for the lifetime of the tab.
|
|
#[wasm_bindgen]
|
|
#[derive(Debug, Default)]
|
|
pub struct Client {
|
|
state: ClientState,
|
|
}
|
|
|
|
/// An empty view slice and one empty cache per widget. Not exported: the
|
|
/// page's door is `new Client()` (improvise-seik).
|
|
pub fn init() -> Client {
|
|
Client::default()
|
|
}
|
|
|
|
/// One frame as the JSON that crosses the wire. Separate so a serialization
|
|
/// failure cannot be spelled `.ok()` and read as "nothing to send" (r4nf).
|
|
fn frame_json(frame: &ClientFrame) -> Result<String, serde_json::Error> {
|
|
serde_json::to_string(frame)
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl Client {
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Resolve a browser `KeyboardEvent` into the `ClientFrame` to send, or
|
|
/// `None`. A locally-resolved command has *already been folded* here.
|
|
#[wasm_bindgen]
|
|
pub fn on_key_event(
|
|
&mut self,
|
|
key: &str,
|
|
ctrl: bool,
|
|
alt: bool,
|
|
shift: bool,
|
|
meta: bool,
|
|
) -> Result<Option<String>, JsError> {
|
|
let browser = BrowserKey {
|
|
key: key.to_string(),
|
|
ctrl,
|
|
alt,
|
|
shift,
|
|
meta,
|
|
};
|
|
self.key(&browser)
|
|
.map(|f| frame_json(&f))
|
|
.transpose()
|
|
.map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// Has anything moved? The cheap question, and the one `on_frame` already
|
|
/// answers by returning it.
|
|
#[wasm_bindgen]
|
|
pub fn revision(&self) -> u64 {
|
|
self.state.revision
|
|
}
|
|
|
|
// The transport surface: a handful of string calls and the whole harness a
|
|
// page needs. See `context/code/clients.md`.
|
|
|
|
/// The handshake frame, *and* the record of what this client can show.
|
|
/// The **only** door, and why that matters: `context/code/clients.md`.
|
|
#[wasm_bindgen]
|
|
pub fn set_viewport(&mut self, rows: usize, cols: usize) -> String {
|
|
self.state.window = (rows, cols);
|
|
serde_json::to_string(&Self::viewport_frame(rows, cols))
|
|
// Two `usize` in a unit variant; unreachable, and an empty frame
|
|
// is skipped by the server rather than crashing the tab.
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Fold one frame, in arrival order, one at a time; returns the new
|
|
/// revision. Never reorder or coalesce — a split batch loses cells.
|
|
#[wasm_bindgen]
|
|
pub fn on_frame(&mut self, json: &str) -> Result<u64, JsError> {
|
|
self.handle_frame(json)
|
|
.map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// Everything the page draws, with every rendering decision already
|
|
/// made. See [`render`].
|
|
#[wasm_bindgen]
|
|
pub fn render_model(&self) -> Result<String, JsError> {
|
|
self.model_json().map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// Which stored cell an editor opened at this viewport position edits, as
|
|
/// an opaque string the page holds until commit, or `None` (improvise-2wu3).
|
|
#[wasm_bindgen]
|
|
pub fn edit_target(&self, row: usize, col: usize) -> Result<Option<String>, JsError> {
|
|
self.edit_target_at(row, col)
|
|
.map(|target| serde_json::to_string(&target))
|
|
.transpose()
|
|
.map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// What the editor opened on `target` shows: the seed the target captured,
|
|
/// never the page's paint, which can be stale (improvise-ugj4).
|
|
#[wasm_bindgen]
|
|
pub fn edit_seed(&self, target: &str) -> Result<String, JsError> {
|
|
let target: EditTarget =
|
|
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
|
Ok(target.opened.seed)
|
|
}
|
|
|
|
/// `target` after the user's first edit of its `<input>`, still opaque to
|
|
/// the page (improvise-jibp).
|
|
#[wasm_bindgen]
|
|
pub fn edited_target(&self, target: &str) -> Result<String, JsError> {
|
|
let target: EditTarget =
|
|
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
|
serde_json::to_string(&target.edited()).map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// The frame for "the user typed `text` into the cell `target` names". By
|
|
/// identity, never position: rows renumber while an editor is open.
|
|
#[wasm_bindgen]
|
|
pub fn typed_cell(&mut self, target: &str, text: &str) -> Result<Option<String>, JsError> {
|
|
let target: EditTarget =
|
|
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
|
self.typed_target_frame(target, text)
|
|
.map(|f| frame_json(&f))
|
|
.transpose()
|
|
.map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// The user clicked row `index` of the category panel. Resolved whole —
|
|
/// focus, cursor and activation — and folded here (improvise-7ybz).
|
|
#[wasm_bindgen]
|
|
pub fn click_category(&mut self, index: usize) -> Result<Option<String>, JsError> {
|
|
self.clicked(Click::CategoryRow(index))
|
|
}
|
|
|
|
/// The user clicked row `index` of the view panel: focus, move, switch.
|
|
#[wasm_bindgen]
|
|
pub fn click_view(&mut self, index: usize) -> Result<Option<String>, JsError> {
|
|
self.clicked(Click::ViewRow(index))
|
|
}
|
|
|
|
/// The user clicked row `index` of the formula panel: focus it, move there,
|
|
/// then open the editor on that formula, as `Enter` does (improvise-km71).
|
|
#[wasm_bindgen]
|
|
pub fn click_formula(&mut self, index: usize) -> Result<Option<String>, JsError> {
|
|
self.clicked(Click::FormulaRow(index))
|
|
}
|
|
|
|
/// The user clicked tile `index`: select it and cycle its axis.
|
|
#[wasm_bindgen]
|
|
pub fn click_tile(&mut self, index: usize) -> Result<Option<String>, JsError> {
|
|
self.clicked(Click::Tile(index))
|
|
}
|
|
|
|
/// The user picked item `item` off page axis `axis`, both indexing what
|
|
/// the bar was painted from. A Filter axis composes nothing (gotcha 27).
|
|
#[wasm_bindgen]
|
|
pub fn click_page_item(&mut self, axis: usize, item: usize) -> Result<Option<String>, JsError> {
|
|
self.clicked(Click::PageItem { axis, item })
|
|
}
|
|
|
|
/// The user clicked a grid cell at a **viewport** position: move the
|
|
/// selection there. Opening an editor is the page's own affair.
|
|
#[wasm_bindgen]
|
|
pub fn click_cell(&mut self, row: usize, col: usize) -> Result<Option<String>, JsError> {
|
|
self.clicked(Click::Cell { row, col })
|
|
}
|
|
}
|
|
|
|
/// The same surface in plain Rust, for tests and for any host that is not JS.
|
|
/// Why the split is not merely ergonomic: `context/code/clients.md`.
|
|
impl Client {
|
|
pub fn state(&self) -> &ClientState {
|
|
&self.state
|
|
}
|
|
|
|
/// The fold's target. `pub` only because tests in sibling modules drive
|
|
/// it; nothing outside this crate should fold behind a client's back.
|
|
pub fn state_mut(&mut self) -> &mut ClientState {
|
|
&mut self.state
|
|
}
|
|
|
|
/// The address of the stored cell at a viewport position, or `None` on the
|
|
/// other shapes, which are not one. The last is not a check (ADR 002).
|
|
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
|
|
match self.state.grid.cell(row, col)?.id.clone()? {
|
|
CellId::Record(cell) => Some(cell),
|
|
CellId::Aggregate(_)
|
|
| CellId::Derived { .. }
|
|
| CellId::Create(_)
|
|
| CellId::Coordinate
|
|
| CellId::NonItem { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub fn state_json(&self) -> Result<String, serde_json::Error> {
|
|
serde_json::to_string(&self.state)
|
|
}
|
|
|
|
pub fn bootstrap(&mut self, snapshot: Snapshot) {
|
|
self.state.bootstrap(snapshot);
|
|
}
|
|
|
|
/// Resolve a click, fold it, and hand back the frame to send. The twin of
|
|
/// [`Client::key`]'s `Local` arm: a click is never the host's to resolve.
|
|
pub fn click_frame(&mut self, target: Click) -> Option<ClientFrame> {
|
|
let cmds = improvise_protocol::click(&self.state, target);
|
|
if cmds.is_empty() {
|
|
return None;
|
|
}
|
|
self.state.fold_outgoing(&cmds);
|
|
Some(ClientFrame::Commands(cmds))
|
|
}
|
|
|
|
fn clicked(&mut self, target: Click) -> Result<Option<String>, JsError> {
|
|
self.click_frame(target)
|
|
.map(|f| frame_json(&f))
|
|
.transpose()
|
|
.map_err(|e| JsError::new(&e.to_string()))
|
|
}
|
|
|
|
/// Classify a keypress, folding it locally when this client can answer it.
|
|
pub fn key(&mut self, browser: &BrowserKey) -> Option<ClientFrame> {
|
|
let event = browser.to_key_event()?;
|
|
match resolve(&self.state, event) {
|
|
KeyOutcome::Local(cmds) => {
|
|
self.state.fold_outgoing(&cmds);
|
|
Some(ClientFrame::Commands(cmds))
|
|
}
|
|
// Nothing to fold: a request moves no state of this client's, and
|
|
// what it moves on the host comes back as a projection.
|
|
KeyOutcome::Request(request) => Some(request.frame()),
|
|
KeyOutcome::Upstream(event) => Some(ClientFrame::key(event)),
|
|
KeyOutcome::Ignored => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use improvise_protocol::{
|
|
AppMode, AxisFrame, Command, GridUpdate, Projection, ServerFrame, ViewState,
|
|
};
|
|
|
|
// ── composing a write (ADR 002) ─────────────────────────────────────
|
|
|
|
/// A surrogate as a client really receives one: 32 hex characters off the
|
|
/// wire. Deserializing is the only way a client ever comes by one.
|
|
fn surrogate(hex: &str) -> improvise_protocol::RecordHash {
|
|
serde_json::from_str(&format!("\"{hex}\"")).expect("a surrogate is hex")
|
|
}
|
|
|
|
/// The fourth `None`, and the one ADR 001 adds: no published basis means
|
|
/// nothing truthful to claim. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_client_with_no_basis_composes_nothing() {
|
|
let id = improvise_protocol::RecordCell {
|
|
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
|
|
measure: Some("Revenue".into()),
|
|
};
|
|
let mut cell = improvise_protocol::CellDisplay::number("1200");
|
|
cell.id = Some(CellId::Record(id));
|
|
let mut client = init();
|
|
client.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![
|
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
|
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
|
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
|
|
],
|
|
});
|
|
assert_eq!(client.typed_cell_frame(0, 0, "42"), None);
|
|
}
|
|
|
|
/// BUG (improvise-ugj4, improvise-jibp) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn the_page_opens_its_editor_on_what_the_target_captured() {
|
|
let id = improvise_protocol::RecordCell {
|
|
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
|
|
measure: Some("Revenue".into()),
|
|
};
|
|
let at = |text: &str, stored: &str| {
|
|
let mut cell = improvise_protocol::CellDisplay::number(text);
|
|
cell.edit_text = Some(stored.into());
|
|
cell.id = Some(CellId::Record(id.clone()));
|
|
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)]))
|
|
};
|
|
let mut client = init();
|
|
client.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![
|
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
|
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
|
at("1,200.50", "1200.5"),
|
|
Projection::Basis(improvise_protocol::basis::published(
|
|
0,
|
|
0,
|
|
[(id.record, 0)].into_iter().collect(),
|
|
)),
|
|
],
|
|
});
|
|
let first = client.edit_target(0, 0).unwrap().expect("a record cell");
|
|
assert_eq!(
|
|
client.edit_seed(&first).unwrap(),
|
|
"1200.5",
|
|
"not the drawn text"
|
|
);
|
|
client.fold_frame(ServerFrame::Commands(vec![Command::Project(at(
|
|
"777", "777",
|
|
))]));
|
|
let later = client.edit_target(0, 0).unwrap().expect("a record cell");
|
|
assert_eq!(
|
|
client.edit_seed(&later).unwrap(),
|
|
"777",
|
|
"not the cache at open"
|
|
);
|
|
assert_eq!(
|
|
client.edit_seed(&first).unwrap(),
|
|
"1200.5",
|
|
"a target is one capture"
|
|
);
|
|
|
|
assert!(client.typed_cell(&later, "777").unwrap().is_none());
|
|
let edited = client.edited_target(&later).unwrap();
|
|
assert!(
|
|
client.typed_cell(&edited, "777").unwrap().is_some(),
|
|
"an edited editor typed back to its seed composed nothing"
|
|
);
|
|
}
|
|
|
|
/// The end-to-end shape a JS harness drives: bootstrap, key, message.
|
|
#[test]
|
|
fn a_client_can_be_driven_entirely_through_json() {
|
|
let mut client = init();
|
|
let snapshot = ServerFrame::bootstrap(Snapshot {
|
|
view: ViewState {
|
|
mode: AppMode::category_add(),
|
|
..ViewState::default()
|
|
},
|
|
seed: vec![Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
|
"North",
|
|
])))],
|
|
});
|
|
assert_eq!(
|
|
client
|
|
.handle_frame(&serde_json::to_string(&snapshot).unwrap())
|
|
.unwrap(),
|
|
1
|
|
);
|
|
|
|
let outbound = client
|
|
.on_key_event("R", false, false, true, false)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<ClientFrame>(&outbound).unwrap(),
|
|
ClientFrame::command(Command::SetBuffer {
|
|
name: "category".into(),
|
|
value: "R".into(),
|
|
}),
|
|
"a typed character is resolved here and echoed upstream"
|
|
);
|
|
|
|
let rendered: serde_json::Value =
|
|
serde_json::from_str(&client.state_json().unwrap()).unwrap();
|
|
assert_eq!(
|
|
rendered["grid"]["row_frame"]["entries"][0]["Item"][0],
|
|
"North"
|
|
);
|
|
assert_eq!(rendered["view"]["buffers"]["category"], "R");
|
|
assert_eq!(rendered["revision"], client.revision());
|
|
}
|
|
|
|
/// `Enter` rather than an arrow: since improvise-bpq2 the arrows resolve
|
|
/// here, and `enter-advance` is one of the movements left to the host.
|
|
#[test]
|
|
fn an_unresolvable_key_is_forwarded_and_changes_nothing_locally() {
|
|
let mut client = init();
|
|
let before = client.revision();
|
|
let outbound = client
|
|
.on_key_event("Enter", false, false, false, false)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
outbound.contains("Key"),
|
|
"a Normal-mode Enter is the host's to resolve: {outbound}"
|
|
);
|
|
assert_eq!(
|
|
client.revision(),
|
|
before,
|
|
"forwarding must not move the client optimistically"
|
|
);
|
|
}
|
|
|
|
/// A client with no grid and no announced window answers its own cursor
|
|
/// keys with **nothing** — the host has no better answer either.
|
|
#[test]
|
|
fn a_cursor_key_before_the_handshake_produces_no_message() {
|
|
let mut client = init();
|
|
assert_eq!(
|
|
client
|
|
.on_key_event("ArrowDown", false, false, false, false)
|
|
.unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// The handshake frame, and the window it records — one call, because a
|
|
/// second door that sent the frame and forgot the window was silent.
|
|
#[test]
|
|
fn set_viewport_sends_the_handshake_frame_and_keeps_the_window() {
|
|
let mut client = init();
|
|
assert_eq!(
|
|
serde_json::from_str::<ClientFrame>(&client.set_viewport(24, 8)).unwrap(),
|
|
Client::viewport_frame(24, 8)
|
|
);
|
|
assert_eq!(client.state().window, (24, 8));
|
|
}
|
|
|
|
#[test]
|
|
fn a_browser_shortcut_produces_no_message_at_all() {
|
|
let mut client = init();
|
|
assert_eq!(
|
|
client.on_key_event("r", false, false, false, true).unwrap(),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
client
|
|
.on_key_event("Shift", false, false, true, false)
|
|
.unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_json_is_an_error_rather_than_a_panic() {
|
|
let mut client = init();
|
|
assert!(client.handle_frame("{ not json").is_err());
|
|
assert_eq!(client.revision(), 0, "a rejected message folds nothing");
|
|
}
|
|
}
|