feat(wasm-client): the browser-side fold and key classifier (improvise-gsw)
A new crate `improvise-wasm-client`: a `ViewState` plus one widget cache each, moved by folding the same `Command` stream the host folds, with a wasm-bindgen surface over JSON. The ticket's premise does not survive contact with the architecture (`doctrine(check_the_ticket_premise)`). It asks `on_key_event` to "resolve via the keymap and dispatch through reduce_view", but a binding names an *intent* resolved against a `CmdContext` — a `Model`, a `GridLayout`, an `App` — while a `Command` is the *resolved mutation* (gotcha 38, improvise-gxi). A thin client holds none of that, so keys travel upstream unresolved and `improvise-command` is a dev-dependency rather than a dependency: the keymap could not be used here even if the bundle could afford `App`, `Workbook`, persistence and the wizard. The one exception is minibuffer text entry, whose target buffer is `AppMode::minibuffer().buffer_key` — the same field the host's `TextEntry` builder reads (gotcha 11), so the local rule is derived from the same fact rather than copied. `tests/lockstep.rs` drives the authoritative host and the client over the same keypress in all seven text-entry modes and asserts the two `ViewState`s agree. Rejected for local resolution, with reasons in `keys.rs`: cursor movement (`GridCache` is viewport-indexed, so a client cannot clamp), panel cursors (the flattening depends on `expanded_cats`), anything modified (`AnyChar` matches under any modifier on the host), and CommandMode's Backspace (the one `TextEntry::rebind`, which leaves the mode). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
@@ -43,6 +43,12 @@ jobs:
|
||||
run: cargo check -p improvise-io --target wasm32-unknown-unknown
|
||||
- name: improvise-command
|
||||
run: cargo check -p improvise-command --target wasm32-unknown-unknown
|
||||
# The browser client itself. This is the one crate for which a wasm
|
||||
# build is the *point* rather than a property worth keeping, so a
|
||||
# failure here is not a portability regression but a broken deliverable
|
||||
# (improvise-gsw).
|
||||
- name: improvise-wasm-client
|
||||
run: cargo check -p improvise-wasm-client --target wasm32-unknown-unknown
|
||||
# Not in the browser bundle yet — nothing depends on improvise-gp. Gated
|
||||
# anyway because it is a candidate (fitting a tree in the browser) and
|
||||
# staying wasm-clean is free now, expensive to retrofit (improvise-z5eg).
|
||||
|
||||
Generated
+12
@@ -837,6 +837,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "improvise-wasm-client"
|
||||
version = "0.1.0-rc2"
|
||||
dependencies = [
|
||||
"improvise-command",
|
||||
"improvise-core",
|
||||
"improvise-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/gp-tree", "crates/improvise-gp"]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/gp-tree", "crates/improvise-gp"]
|
||||
|
||||
[package]
|
||||
name = "improvise"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "improvise-wasm-client"
|
||||
version = "0.1.0-rc2"
|
||||
edition = "2024"
|
||||
description = "Browser-side improvise client: folds the wire vocabulary, resolves browser keys"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
improvise-protocol = { path = "../improvise-protocol" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
# Deliberately NOT a dependency: `improvise-command`. A keymap binding names
|
||||
# an intent resolved against a `CmdContext` — a `Model`, a `GridLayout` and an
|
||||
# `App` this client does not have (gotcha 38, improvise-gxi). Depending on it
|
||||
# would drag the whole host — `App`, `Workbook`, persistence, the import
|
||||
# wizard — into the browser bundle to reach a lookup we could not use.
|
||||
#
|
||||
# It IS a dev-dependency: `tests/lockstep.rs` drives the authoritative host and
|
||||
# this client over the same keypress and asserts their `ViewState`s agree. Dev
|
||||
# dependencies are not built for `--target wasm32-unknown-unknown`, so the
|
||||
# browser bundle is unaffected.
|
||||
[dev-dependencies]
|
||||
improvise-command = { path = "../improvise-command" }
|
||||
improvise-core = { path = "../improvise-core" }
|
||||
@@ -0,0 +1,459 @@
|
||||
//! Browser `KeyboardEvent` → improvise key, and the one question that
|
||||
//! follows: can this client answer the keypress itself?
|
||||
//!
|
||||
//! # What a browser client may resolve locally, and why it is so little
|
||||
//!
|
||||
//! Almost nothing. A keymap binding names an **intent** — `move-selection
|
||||
//! down`, `commit-buffer category` — and turning an intent into a mutation
|
||||
//! means running `Cmd::execute` against a `CmdContext`: the `Model`, the
|
||||
//! `GridLayout`, the cursor, the yanked cell. A thin client holds none of
|
||||
//! that. `Command` is the *resolved* mutation (`SetSelected { row, col }`),
|
||||
//! which is why a keymap lookup cannot return one (gotcha 38; improvise-gxi
|
||||
//! closed on exactly this). So the default, and the answer for every key not
|
||||
//! named below, is [`KeyOutcome::Upstream`]: send the keypress and let the
|
||||
//! host resolve it against the authoritative `App`.
|
||||
//!
|
||||
//! The exception is **minibuffer text entry**, and it is an exception for a
|
||||
//! structural reason rather than a convenient one. In a text-entry mode the
|
||||
//! buffer a character lands in is not a fact about the document at all: it is
|
||||
//! `AppMode::minibuffer().buffer_key`, which lives in `improvise-protocol` on
|
||||
//! the mode the client already holds. The host's keymap reads the *same*
|
||||
//! field — `TextEntry::new` takes the `AppMode` and gets `<buf>` off its
|
||||
//! `MinibufferConfig` precisely so the prompt and the keys cannot name
|
||||
//! different buffers (gotcha 11, improvise-k8h) — so the local rule is
|
||||
//! derived from the same fact the server rule is, not copied from it. That
|
||||
//! matters because typing is the one interaction where a round trip is
|
||||
//! actually felt.
|
||||
//!
|
||||
//! Three things were considered for local resolution and **rejected**:
|
||||
//!
|
||||
//! - **Cursor movement.** Tempting, and the highest-value latency win. But
|
||||
//! `GridCache` is indexed by position *within the viewport*, not within the
|
||||
//! grid — a client cannot tell "the last visible row" from "the last row",
|
||||
//! so it cannot clamp, and a cursor clamped wrong is worse than a cursor
|
||||
//! that waits.
|
||||
//! - **Panel cursor movement.** The panel caches do carry their full item
|
||||
//! lists, so the clamp is available. It is still upstream: `move-panel-cursor`
|
||||
//! walks a *flattened* tree whose shape depends on `expanded_cats`, and
|
||||
//! re-deriving the flattening client-side is how two ends drift.
|
||||
//! - **Anything under Ctrl or Alt.** `KeyPattern::AnyChar` matches a `Char`
|
||||
//! with *any* modifiers, so `Ctrl-x` in a text-entry mode does reach
|
||||
//! `append-char` on the host unless that mode binds it exactly. Rather than
|
||||
//! mirror that fallback order, this client resolves only unmodified
|
||||
//! characters. Local resolution may always be a strict subset of what the
|
||||
//! host would do; it may never be a superset.
|
||||
//!
|
||||
//! One key is upstream *despite* being a text-entry key: Backspace in
|
||||
//! `CommandMode`. That mode is the single user of `TextEntry::rebind`, and
|
||||
//! its Backspace leaves the mode on an empty buffer instead of popping
|
||||
//! (gotcha 11). A client that popped it locally would sit in `:` after the
|
||||
//! host had left it.
|
||||
|
||||
use improvise_protocol::{AppMode, Command, KeyCode, KeyEvent, KeyModifiers, ViewState};
|
||||
|
||||
/// A browser `KeyboardEvent`, flattened to the fields that matter.
|
||||
///
|
||||
/// `key` is `KeyboardEvent.key` — the printed character for a character key,
|
||||
/// a named key like `"ArrowUp"` otherwise.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BrowserKey {
|
||||
pub key: String,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
pub shift: bool,
|
||||
pub meta: bool,
|
||||
}
|
||||
|
||||
impl BrowserKey {
|
||||
pub fn new(key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The improvise key this browser event denotes, or `None` when there is
|
||||
/// nothing to send.
|
||||
///
|
||||
/// `None` covers two cases the host must never see. A bare modifier press
|
||||
/// (`"Shift"`, `"Control"`, …) is not a keypress. And anything held with
|
||||
/// the platform meta key is left to the browser: `KeyModifiers` has no
|
||||
/// bit for Meta, so forwarding `Cmd-R` would arrive as a plain `r` and
|
||||
/// type into a cell instead of reloading the page.
|
||||
pub fn to_key_event(&self) -> Option<KeyEvent> {
|
||||
if self.meta || matches!(self.key.as_str(), "Shift" | "Control" | "Alt" | "Meta") {
|
||||
return None;
|
||||
}
|
||||
let code = self.to_key_code()?;
|
||||
Some(KeyEvent::new(code, self.modifiers(code)))
|
||||
}
|
||||
|
||||
fn to_key_code(&self) -> Option<KeyCode> {
|
||||
let named = match self.key.as_str() {
|
||||
"ArrowUp" => KeyCode::Up,
|
||||
"ArrowDown" => KeyCode::Down,
|
||||
"ArrowLeft" => KeyCode::Left,
|
||||
"ArrowRight" => KeyCode::Right,
|
||||
"Enter" => KeyCode::Enter,
|
||||
"Escape" => KeyCode::Esc,
|
||||
// A terminal reports Shift-Tab as its own code, and the keymaps
|
||||
// bind that code; the browser reports Tab plus a shift flag.
|
||||
"Tab" if self.shift => KeyCode::BackTab,
|
||||
"Tab" => KeyCode::Tab,
|
||||
"Backspace" => KeyCode::Backspace,
|
||||
"Delete" => KeyCode::Delete,
|
||||
"Home" => KeyCode::Home,
|
||||
"End" => KeyCode::End,
|
||||
"PageUp" => KeyCode::PageUp,
|
||||
"PageDown" => KeyCode::PageDown,
|
||||
other => return function_key(other).or_else(|| single_char(other)),
|
||||
};
|
||||
Some(named)
|
||||
}
|
||||
|
||||
/// Shift is deliberately dropped for character keys: the browser already
|
||||
/// applied it (`"A"`, not shift+`"a"`), and the host's `lookup_local`
|
||||
/// retries character keys without modifiers for that very reason. Keeping
|
||||
/// the flag would only make an exact binding miss.
|
||||
fn modifiers(&self, code: KeyCode) -> KeyModifiers {
|
||||
let mut mods = KeyModifiers::NONE;
|
||||
if self.ctrl {
|
||||
mods = mods | KeyModifiers::CONTROL;
|
||||
}
|
||||
if self.alt {
|
||||
mods = mods | KeyModifiers::ALT;
|
||||
}
|
||||
if self.shift && !matches!(code, KeyCode::Char(_)) {
|
||||
mods = mods | KeyModifiers::SHIFT;
|
||||
}
|
||||
mods
|
||||
}
|
||||
}
|
||||
|
||||
fn function_key(name: &str) -> Option<KeyCode> {
|
||||
name.strip_prefix('F')
|
||||
.filter(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
|
||||
.and_then(|rest| rest.parse::<u8>().ok())
|
||||
.map(KeyCode::F)
|
||||
}
|
||||
|
||||
fn single_char(name: &str) -> Option<KeyCode> {
|
||||
let mut chars = name.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) => Some(KeyCode::Char(c)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What this client decided to do with a keypress.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum KeyOutcome {
|
||||
/// Resolved here. The caller folds it locally *and* sends it, so the
|
||||
/// host's mirror of this client moves the same way (gotcha 36: the
|
||||
/// asker's own fold is its own; the host forwards only to the others).
|
||||
Local(Command),
|
||||
/// Not resolvable without the document. The host resolves it.
|
||||
Upstream(KeyEvent),
|
||||
/// Nothing to send.
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// Decide a keypress against the view slice alone.
|
||||
///
|
||||
/// Pure, and deliberately conservative — see the module docs. The rule reads
|
||||
/// `AppMode::minibuffer()`, which is the same field the host's `TextEntry`
|
||||
/// keymap builder reads.
|
||||
pub fn resolve(view: &ViewState, event: KeyEvent) -> KeyOutcome {
|
||||
let Some(minibuf) = view.mode.minibuffer() else {
|
||||
return KeyOutcome::Upstream(event);
|
||||
};
|
||||
// `read_buffer`/`write_buffer` treat the name "search" as the
|
||||
// `search_query` field rather than an entry in `buffers`. No text-entry
|
||||
// mode uses that name (`no_text_entry_mode_types_into_the_search_buffer`
|
||||
// holds it), so the rule below does not need the special case — but it
|
||||
// must not silently acquire it either.
|
||||
if minibuf.buffer_key == "search" || view.search_mode {
|
||||
return KeyOutcome::Upstream(event);
|
||||
}
|
||||
let current = || {
|
||||
view.buffers
|
||||
.get(&minibuf.buffer_key)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let set = |value: String| {
|
||||
KeyOutcome::Local(Command::SetBuffer {
|
||||
name: minibuf.buffer_key.clone(),
|
||||
value,
|
||||
})
|
||||
};
|
||||
match event.code {
|
||||
KeyCode::Char(c) if event.modifiers == KeyModifiers::NONE => {
|
||||
let mut value = current();
|
||||
value.push(c);
|
||||
set(value)
|
||||
}
|
||||
// CommandMode rebinds Backspace to a command that leaves the mode on
|
||||
// an empty buffer, so it is not ours to answer.
|
||||
KeyCode::Backspace
|
||||
if event.modifiers == KeyModifiers::NONE
|
||||
&& !matches!(view.mode, AppMode::CommandMode { .. }) =>
|
||||
{
|
||||
let mut value = current();
|
||||
value.pop();
|
||||
set(value)
|
||||
}
|
||||
_ => KeyOutcome::Upstream(event),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ev(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// Every text-entry mode, built by its own constructor so a new one shows
|
||||
/// up here as a missing line rather than as untested behaviour.
|
||||
fn text_entry_modes() -> Vec<AppMode> {
|
||||
vec![
|
||||
AppMode::editing(),
|
||||
AppMode::records_editing(),
|
||||
AppMode::formula_edit(),
|
||||
AppMode::command_mode(),
|
||||
AppMode::category_add(),
|
||||
AppMode::item_add("Region".into()),
|
||||
AppMode::export_prompt(),
|
||||
]
|
||||
}
|
||||
|
||||
fn in_mode(mode: AppMode) -> ViewState {
|
||||
ViewState {
|
||||
mode,
|
||||
..ViewState::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── browser → improvise key ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn named_browser_keys_map_to_their_codes() {
|
||||
for (name, code) in [
|
||||
("ArrowUp", KeyCode::Up),
|
||||
("ArrowDown", KeyCode::Down),
|
||||
("ArrowLeft", KeyCode::Left),
|
||||
("ArrowRight", KeyCode::Right),
|
||||
("Enter", KeyCode::Enter),
|
||||
("Escape", KeyCode::Esc),
|
||||
("Tab", KeyCode::Tab),
|
||||
("Backspace", KeyCode::Backspace),
|
||||
("Delete", KeyCode::Delete),
|
||||
("Home", KeyCode::Home),
|
||||
("End", KeyCode::End),
|
||||
("PageUp", KeyCode::PageUp),
|
||||
("PageDown", KeyCode::PageDown),
|
||||
("F3", KeyCode::F(3)),
|
||||
("F12", KeyCode::F(12)),
|
||||
("j", KeyCode::Char('j')),
|
||||
(":", KeyCode::Char(':')),
|
||||
("é", KeyCode::Char('é')),
|
||||
] {
|
||||
assert_eq!(
|
||||
BrowserKey::new(name).to_key_event().map(|e| e.code),
|
||||
Some(code),
|
||||
"{name} should map to {code:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_is_its_own_code_as_a_terminal_reports_it() {
|
||||
let shifted = BrowserKey {
|
||||
shift: true,
|
||||
..BrowserKey::new("Tab")
|
||||
};
|
||||
assert_eq!(shifted.to_key_event().unwrap().code, KeyCode::BackTab);
|
||||
}
|
||||
|
||||
/// Shift is already in the character, and the host's lookup retries
|
||||
/// character keys without modifiers — carrying the flag can only make an
|
||||
/// exact binding miss.
|
||||
#[test]
|
||||
fn shift_is_dropped_for_characters_and_kept_for_named_keys() {
|
||||
let shifted_char = BrowserKey {
|
||||
shift: true,
|
||||
..BrowserKey::new("Z")
|
||||
};
|
||||
let event = shifted_char.to_key_event().unwrap();
|
||||
assert_eq!(event.code, KeyCode::Char('Z'));
|
||||
assert_eq!(event.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let shifted_named = BrowserKey {
|
||||
shift: true,
|
||||
..BrowserKey::new("ArrowUp")
|
||||
};
|
||||
assert_eq!(
|
||||
shifted_named.to_key_event().unwrap().modifiers,
|
||||
KeyModifiers::SHIFT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_and_alt_survive_the_conversion() {
|
||||
let key = BrowserKey {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
..BrowserKey::new("d")
|
||||
};
|
||||
let event = key.to_key_event().unwrap();
|
||||
assert_eq!(event.code, KeyCode::Char('d'));
|
||||
assert!(event.modifiers.contains(KeyModifiers::CONTROL));
|
||||
assert!(event.modifiers.contains(KeyModifiers::ALT));
|
||||
}
|
||||
|
||||
/// Two things the host must never be told about: a bare modifier press,
|
||||
/// and a shortcut the browser owns. `KeyModifiers` has no Meta bit, so a
|
||||
/// forwarded `Cmd-R` would arrive as a plain `r`.
|
||||
#[test]
|
||||
fn bare_modifiers_and_meta_chords_are_not_keypresses() {
|
||||
for name in ["Shift", "Control", "Alt", "Meta"] {
|
||||
assert_eq!(BrowserKey::new(name).to_key_event(), None, "{name}");
|
||||
}
|
||||
let meta = BrowserKey {
|
||||
meta: true,
|
||||
..BrowserKey::new("r")
|
||||
};
|
||||
assert_eq!(meta.to_key_event(), None);
|
||||
assert_eq!(BrowserKey::new("Unidentified").to_key_event(), None);
|
||||
}
|
||||
|
||||
// ── local vs upstream ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn typing_a_character_in_any_text_entry_mode_resolves_locally() {
|
||||
for mode in text_entry_modes() {
|
||||
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
||||
let mut view = in_mode(mode.clone());
|
||||
view.buffers.insert(buffer.clone(), "Reg".into());
|
||||
assert_eq!(
|
||||
resolve(&view, ev(KeyCode::Char('i'))),
|
||||
KeyOutcome::Local(Command::SetBuffer {
|
||||
name: buffer,
|
||||
value: "Regi".into(),
|
||||
}),
|
||||
"{} should type locally",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_pops_locally_everywhere_except_command_mode() {
|
||||
for mode in text_entry_modes() {
|
||||
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
||||
let mut view = in_mode(mode.clone());
|
||||
view.buffers.insert(buffer.clone(), "Regi".into());
|
||||
let outcome = resolve(&view, ev(KeyCode::Backspace));
|
||||
if matches!(mode, AppMode::CommandMode { .. }) {
|
||||
assert_eq!(
|
||||
outcome,
|
||||
KeyOutcome::Upstream(ev(KeyCode::Backspace)),
|
||||
"CommandMode rebinds Backspace to a command that leaves the mode"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
outcome,
|
||||
KeyOutcome::Local(Command::SetBuffer {
|
||||
name: buffer,
|
||||
value: "Reg".into(),
|
||||
}),
|
||||
"{} should pop locally",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_or_cancel_key_is_never_resolved_locally() {
|
||||
for mode in text_entry_modes() {
|
||||
let view = in_mode(mode.clone());
|
||||
for code in [KeyCode::Enter, KeyCode::Esc, KeyCode::Tab, KeyCode::Up] {
|
||||
assert_eq!(
|
||||
resolve(&view, ev(code)),
|
||||
KeyOutcome::Upstream(ev(code)),
|
||||
"{} / {code:?} needs the host",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local resolution may be a strict subset of the host's behaviour and
|
||||
/// never a superset; `AnyChar` matches under any modifier on the host, so
|
||||
/// a modified character is left to it.
|
||||
#[test]
|
||||
fn a_modified_character_goes_upstream_even_in_a_text_entry_mode() {
|
||||
let view = in_mode(AppMode::editing());
|
||||
let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||||
assert_eq!(resolve(&view, event), KeyOutcome::Upstream(event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_in_a_non_text_mode_resolves_locally() {
|
||||
for mode in [
|
||||
AppMode::Normal,
|
||||
AppMode::RecordsNormal,
|
||||
AppMode::TileSelect,
|
||||
AppMode::CategoryPanel,
|
||||
AppMode::FormulaPanel,
|
||||
AppMode::ViewPanel,
|
||||
AppMode::Help,
|
||||
AppMode::ImportWizard,
|
||||
] {
|
||||
let view = in_mode(mode.clone());
|
||||
for code in [KeyCode::Char('j'), KeyCode::Down, KeyCode::Enter] {
|
||||
assert_eq!(
|
||||
resolve(&view, ev(code)),
|
||||
KeyOutcome::Upstream(ev(code)),
|
||||
"{} / {code:?}",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's `read_buffer`/`write_buffer` route the name "search" to
|
||||
/// `search_query` rather than to `buffers`. This client does not carry
|
||||
/// that special case, which is only safe while no text-entry mode uses
|
||||
/// the name — search is `Normal` plus a flag, not a minibuffer mode.
|
||||
#[test]
|
||||
fn no_text_entry_mode_types_into_the_search_buffer() {
|
||||
for mode in text_entry_modes() {
|
||||
assert_ne!(
|
||||
mode.minibuffer().unwrap().buffer_key,
|
||||
"search",
|
||||
"{} would need the search special case",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search mode is `Normal` with a flag, so the guard above cannot fire —
|
||||
/// but if a future mode carried both, the flag wins and the key goes to
|
||||
/// the host that owns the query.
|
||||
#[test]
|
||||
fn search_mode_always_goes_upstream() {
|
||||
let mut view = in_mode(AppMode::editing());
|
||||
view.search_mode = true;
|
||||
assert_eq!(
|
||||
resolve(&view, ev(KeyCode::Char('q'))),
|
||||
KeyOutcome::Upstream(ev(KeyCode::Char('q')))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! The browser-side improvise client (improvise-gsw).
|
||||
//!
|
||||
//! One `wasm32` artifact, reused literally in both planned deployments: the
|
||||
//! thin client talking to a server over a WebSocket (epic improvise-6jk) and
|
||||
//! the standalone app talking to a worker that hosts the `App` over a
|
||||
//! `MessageChannel` (epic improvise-tm6). Only the JS bootstrap differs —
|
||||
//! nothing here knows what the transport is. `improvise-cr3` is the DOM
|
||||
//! renderer and consumes [`Client::render_state`]; it is not this crate.
|
||||
//!
|
||||
//! # What this crate is, structurally
|
||||
//!
|
||||
//! A **fold and a key classifier**, and nothing else. It holds a
|
||||
//! [`ViewState`](improvise_protocol::ViewState) plus one widget cache each —
|
||||
//! there is no aggregate render cache (gotcha 32) — and it moves them by
|
||||
//! folding the same [`Command`](improvise_protocol::Command) stream the host
|
||||
//! folds. The fold is in [`state`]; the key classifier is in [`keys`], and
|
||||
//! its module docs carry the design argument that matters:
|
||||
//!
|
||||
//! > A keymap binding names an **intent** resolved against a `CmdContext`; a
|
||||
//! > `Command` is the **resolved mutation**. A thin client holds no `Model`
|
||||
//! > and no `GridLayout`, so it cannot do that resolution (gotcha 38,
|
||||
//! > improvise-gxi). Keys therefore travel to the host *unresolved*, except
|
||||
//! > for minibuffer text entry, whose target buffer is a fact about the
|
||||
//! > `AppMode` the client already holds.
|
||||
//!
|
||||
//! That is why `improvise-command` is a **dev**-dependency and not a
|
||||
//! dependency: the keymap it owns could not be used here even if the bundle
|
||||
//! could afford `App`, `Workbook`, persistence and the import wizard. What
|
||||
//! the dev-dependency buys is `tests/lockstep.rs`, which drives the
|
||||
//! authoritative host and this client over the same keypress and asserts the
|
||||
//! two `ViewState`s agree — the correspondence is checked rather than
|
||||
//! asserted in prose.
|
||||
//!
|
||||
//! # The boundary
|
||||
//!
|
||||
//! Everything crossing it is JSON, so the JS side needs no generated
|
||||
//! structural bindings and the same strings go on a socket unchanged. No
|
||||
//! `std::fs` (gotcha 2 — it compiles for wasm and fails in a browser) and no
|
||||
//! clock: nothing here calls `Instant::now()`, so the `web-time` shim
|
||||
//! `improvise-command` needs is not needed here. Both are structural, not
|
||||
//! disciplined: this crate depends only on `improvise-protocol`, `serde`,
|
||||
//! `serde_json` and `wasm-bindgen`, none of which reaches a filesystem or a
|
||||
//! clock.
|
||||
//!
|
||||
//! # Ordering
|
||||
//!
|
||||
//! The JS bootstrap must deliver messages to [`Client::on_message`] in
|
||||
//! arrival order, one at a time. The grid fold is deliberately not
|
||||
//! commutative — a `GridUpdate::Cells` that overtakes the labels framing it
|
||||
//! is dropped (gotcha 33) — so batching or reordering silently loses cells.
|
||||
//! For the same reason a locally-resolved command and a forwarded keypress
|
||||
//! must go out on the *same* ordered channel, in the order
|
||||
//! [`Client::on_key_event`] returned them.
|
||||
|
||||
pub mod keys;
|
||||
pub mod state;
|
||||
pub mod wire;
|
||||
|
||||
pub use keys::{BrowserKey, KeyOutcome, resolve};
|
||||
pub use state::ClientState;
|
||||
pub use wire::{ClientMessage, ServerMessage};
|
||||
|
||||
use improvise_protocol::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,
|
||||
}
|
||||
|
||||
/// Allocate a client: an empty view slice and one empty cache per widget.
|
||||
///
|
||||
/// The host's [`Snapshot`] arrives as the first message, so there is nothing
|
||||
/// to configure here — a client that draws before its bootstrap draws an
|
||||
/// empty grid, which is correct.
|
||||
#[wasm_bindgen]
|
||||
pub fn init() -> Client {
|
||||
Client::default()
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Client {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Fold one server→client message, in arrival order.
|
||||
///
|
||||
/// Returns the new revision, so a renderer can compare it against the one
|
||||
/// it last drew without paying for [`Client::render_state`].
|
||||
#[wasm_bindgen]
|
||||
pub fn on_message(&mut self, json: &str) -> Result<u64, JsError> {
|
||||
self.handle_message(json)
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a browser `KeyboardEvent`.
|
||||
///
|
||||
/// Returns the serialized [`ClientMessage`] to send upstream, or `None`
|
||||
/// when there is nothing to send. A locally-resolved command has *already
|
||||
/// been folded* into this client when it is returned — the caller sends
|
||||
/// it so the host's mirror of this client moves too (gotcha 36: the host
|
||||
/// forwards an accepted command to the *other* clients, never back to the
|
||||
/// asker).
|
||||
#[wasm_bindgen]
|
||||
pub fn on_key_event(
|
||||
&mut self,
|
||||
key: &str,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
meta: bool,
|
||||
) -> Option<String> {
|
||||
let browser = BrowserKey {
|
||||
key: key.to_string(),
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
meta,
|
||||
};
|
||||
let message = self.key(&browser)?;
|
||||
serde_json::to_string(&message).ok()
|
||||
}
|
||||
|
||||
/// The whole client state as JSON, for the DOM renderer (improvise-cr3):
|
||||
/// the view slice, every widget cache, and the revision it corresponds to.
|
||||
#[wasm_bindgen]
|
||||
pub fn render_state(&self) -> Result<String, JsError> {
|
||||
self.state_json().map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The cheap half of [`Client::render_state`]: has anything moved?
|
||||
#[wasm_bindgen]
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.state.revision
|
||||
}
|
||||
}
|
||||
|
||||
/// The same surface in plain Rust, for tests and for any host that is not JS.
|
||||
///
|
||||
/// `wasm_bindgen` methods cannot take or return the protocol's types, so the
|
||||
/// bindings above are thin wrappers over these — one code path, serialized at
|
||||
/// the edge. The split is not only for ergonomics: `JsError::new` panics off
|
||||
/// wasm, so every fallible path has to be *reachable* without it or its
|
||||
/// failure arm cannot be tested at all.
|
||||
impl Client {
|
||||
pub fn state(&self) -> &ClientState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Fold one server→client message given as JSON. A message that does not
|
||||
/// parse folds nothing.
|
||||
pub fn handle_message(&mut self, json: &str) -> Result<u64, serde_json::Error> {
|
||||
let message: ServerMessage = serde_json::from_str(json)?;
|
||||
self.apply(message);
|
||||
Ok(self.state.revision)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, message: ServerMessage) {
|
||||
match message {
|
||||
ServerMessage::Bootstrap(snapshot) => self.state.bootstrap(*snapshot),
|
||||
ServerMessage::Command(cmd) => self.state.fold(&cmd),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a keypress, folding it locally when this client can answer it.
|
||||
pub fn key(&mut self, browser: &BrowserKey) -> Option<ClientMessage> {
|
||||
let event = browser.to_key_event()?;
|
||||
match resolve(&self.state.view, event) {
|
||||
KeyOutcome::Local(cmd) => {
|
||||
self.state.fold(&cmd);
|
||||
Some(ClientMessage::Command(cmd))
|
||||
}
|
||||
KeyOutcome::Upstream(event) => Some(ClientMessage::key(event)),
|
||||
KeyOutcome::Ignored => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use improvise_protocol::{AppMode, Command, GridUpdate, Projection, ViewState};
|
||||
|
||||
/// 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 = ServerMessage::bootstrap(Snapshot {
|
||||
view: ViewState {
|
||||
mode: AppMode::category_add(),
|
||||
..ViewState::default()
|
||||
},
|
||||
seed: vec![Projection::Grid(GridUpdate::RowLabels(vec![
|
||||
"North".into(),
|
||||
]))],
|
||||
});
|
||||
assert_eq!(
|
||||
client
|
||||
.handle_message(&serde_json::to_string(&snapshot).unwrap())
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
|
||||
let outbound = client.on_key_event("R", false, false, true, false).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ClientMessage>(&outbound).unwrap(),
|
||||
ClientMessage::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_labels"][0], "North");
|
||||
assert_eq!(rendered["view"]["buffers"]["category"], "R");
|
||||
assert_eq!(rendered["revision"], client.revision());
|
||||
}
|
||||
|
||||
#[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("ArrowDown", false, false, false, false)
|
||||
.unwrap();
|
||||
assert!(
|
||||
outbound.contains("Key"),
|
||||
"a Normal-mode arrow is the host's to resolve: {outbound}"
|
||||
);
|
||||
assert_eq!(
|
||||
client.revision(),
|
||||
before,
|
||||
"forwarding must not move the client optimistically"
|
||||
);
|
||||
}
|
||||
|
||||
#[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), None);
|
||||
assert_eq!(
|
||||
client.on_key_event("Shift", false, false, true, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_an_error_rather_than_a_panic() {
|
||||
let mut client = init();
|
||||
assert!(client.handle_message("{ not json").is_err());
|
||||
assert_eq!(client.revision(), 0, "a rejected message folds nothing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Everything one browser tab knows, and the fold that keeps it current.
|
||||
//!
|
||||
//! There is no aggregate render cache (gotcha 32): the client holds a
|
||||
//! [`ViewState`] plus one [`WidgetCache`] per widget, and routing a
|
||||
//! [`Projection`] to the right cache is *this crate's* dispatch —
|
||||
//! `reduce_view` passes `Command::Project` straight through untouched.
|
||||
//!
|
||||
//! Two properties this module owes, and both are tested here rather than
|
||||
//! assumed from the protocol crate's own tests, because the dispatch between
|
||||
//! them is ours:
|
||||
//!
|
||||
//! - **The stream is not reordered.** The grid's labels frame its cells, so a
|
||||
//! `GridUpdate::Cells` that overtakes them is dropped (gotcha 33). Every
|
||||
//! fold path here is a `for` over arrival order; there is no batching,
|
||||
//! sorting or coalescing, and [`a_cell_projection_that_overtakes_its_labels_is_lost`]
|
||||
//! pins that the drop survives our dispatch.
|
||||
//! - **Bootstrap is a compaction, not a replay** (gotcha 39). [`ClientState::bootstrap`]
|
||||
//! folds `Snapshot::seed` exactly as the steady stream is folded, so there
|
||||
//! is no second code path that could disagree with the incremental one.
|
||||
|
||||
use improvise_protocol::{
|
||||
CategoryTreeCache, Command, FormulaCache, GridCache, Projection, Snapshot, TileBarCache,
|
||||
ViewListCache, ViewState, WidgetCache, reduce_view,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
/// One client's whole state: the portable session slice plus one cache per
|
||||
/// widget.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct ClientState {
|
||||
pub view: ViewState,
|
||||
pub grid: GridCache,
|
||||
pub categories: CategoryTreeCache,
|
||||
pub formulas: FormulaCache,
|
||||
pub views: ViewListCache,
|
||||
pub tiles: TileBarCache,
|
||||
/// Bumped by every fold. A DOM renderer polls this instead of
|
||||
/// re-serializing the whole state to find out whether anything moved
|
||||
/// (improvise-cr3 is the consumer).
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
/// Replace everything with what the host says is current.
|
||||
///
|
||||
/// Used on connect and on a rebase. The caches start empty and are built
|
||||
/// by folding the seed *in the order it arrives*: the seed is a batch of
|
||||
/// ordinary projections, and `GridCache::seed` puts the labels before the
|
||||
/// cells for exactly the reason gotcha 33 gives.
|
||||
pub fn bootstrap(&mut self, snapshot: Snapshot) {
|
||||
self.view = snapshot.view;
|
||||
self.grid = GridCache::default();
|
||||
self.categories = CategoryTreeCache::default();
|
||||
self.formulas = FormulaCache::default();
|
||||
self.views = ViewListCache::default();
|
||||
self.tiles = TileBarCache::default();
|
||||
for projection in &snapshot.seed {
|
||||
self.apply_projection(projection);
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Fold one command. Total and deterministic, like the two folds it
|
||||
/// delegates to — a command naming something the client does not have is
|
||||
/// dropped, never a panic.
|
||||
pub fn fold(&mut self, cmd: &Command) {
|
||||
match cmd {
|
||||
Command::Project(projection) => self.apply_projection(projection),
|
||||
other => reduce_view(&mut self.view, other),
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// The widget dispatch: a projection names the widget it is addressed to,
|
||||
/// and that widget folds it alone. The overlap between `CategoryTreeCache`
|
||||
/// and `TileBarCache` is deliberate — see gotcha 32.
|
||||
fn apply_projection(&mut self, projection: &Projection) {
|
||||
match projection {
|
||||
Projection::Grid(u) => self.grid.apply(u),
|
||||
Projection::CategoryTree(u) => self.categories.apply(u),
|
||||
Projection::Formulas(u) => self.formulas.apply(u),
|
||||
Projection::ViewList(u) => self.views.apply(u),
|
||||
Projection::TileBar(u) => self.tiles.apply(u),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use improvise_core::view::Axis;
|
||||
use improvise_protocol::{
|
||||
CategoryRow, CategoryTreeUpdate, CellDisplay, GridUpdate, TileEntry, ViewSummary,
|
||||
};
|
||||
|
||||
fn folded(cmds: &[Command]) -> ClientState {
|
||||
let mut state = ClientState::default();
|
||||
for cmd in cmds {
|
||||
state.fold(cmd);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn grid_projections() -> Vec<Command> {
|
||||
vec![
|
||||
Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![
|
||||
"North".into(),
|
||||
"South".into(),
|
||||
]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![
|
||||
"Revenue".into(),
|
||||
"Cost".into(),
|
||||
]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::ColWidths(vec![8, 8]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::Cells(vec![
|
||||
((0, 0), CellDisplay::number("1200")),
|
||||
((1, 1), CellDisplay::text("pending")),
|
||||
]))),
|
||||
]
|
||||
}
|
||||
|
||||
/// Gotcha 33, carried across *this* crate's dispatch. The protocol crate
|
||||
/// pins the drop inside `GridCache::apply`; what is ours is that nothing
|
||||
/// between the socket and that cache buffers, sorts or retries — so the
|
||||
/// same early `Cells` is still lost after routing.
|
||||
#[test]
|
||||
fn a_cell_projection_that_overtakes_its_labels_is_lost() {
|
||||
let early = folded(&[
|
||||
Command::Project(Projection::Grid(GridUpdate::Cells(vec![(
|
||||
(0, 0),
|
||||
CellDisplay::number("1200"),
|
||||
)]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![
|
||||
"North".into(),
|
||||
]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![
|
||||
"Revenue".into(),
|
||||
]))),
|
||||
]);
|
||||
assert_eq!(
|
||||
early.grid.cell(0, 0),
|
||||
Some(&CellDisplay::default()),
|
||||
"a Cells update that arrived before its frame must be dropped, not replayed"
|
||||
);
|
||||
|
||||
let ordered = folded(&[
|
||||
Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![
|
||||
"North".into(),
|
||||
]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![
|
||||
"Revenue".into(),
|
||||
]))),
|
||||
Command::Project(Projection::Grid(GridUpdate::Cells(vec![(
|
||||
(0, 0),
|
||||
CellDisplay::number("1200"),
|
||||
)]))),
|
||||
]);
|
||||
assert_eq!(
|
||||
ordered.grid.cell(0, 0),
|
||||
Some(&CellDisplay::number("1200")),
|
||||
"the same three updates in frame-first order do land"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gotcha 39 / `doctrine(fold_tests_must_test_order_not_the_axiom)`: a
|
||||
/// client that bootstraps from a compaction of the host's state must end
|
||||
/// up where a client that folded the whole stream is. The seed is the
|
||||
/// host's *current* state expressed as projections, not the head of the
|
||||
/// history.
|
||||
#[test]
|
||||
fn bootstrapping_from_a_compaction_lands_where_the_stream_lands() {
|
||||
let mut live = folded(&grid_projections());
|
||||
live.fold(&Command::Project(Projection::CategoryTree(
|
||||
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
||||
name: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
)));
|
||||
live.fold(&Command::Project(Projection::Formulas(vec![
|
||||
"Profit = Revenue - Cost".into(),
|
||||
])));
|
||||
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
}])));
|
||||
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}])));
|
||||
live.fold(&Command::SetSelected { row: 1, col: 1 });
|
||||
|
||||
let snapshot = Snapshot {
|
||||
view: live.view.clone(),
|
||||
seed: live
|
||||
.grid
|
||||
.seed()
|
||||
.into_iter()
|
||||
.map(Projection::Grid)
|
||||
.chain([
|
||||
Projection::CategoryTree(CategoryTreeUpdate::Categories(
|
||||
live.categories.categories.clone(),
|
||||
)),
|
||||
Projection::Formulas(live.formulas.formulas.clone()),
|
||||
Projection::ViewList(live.views.views.clone()),
|
||||
Projection::TileBar(live.tiles.tiles.clone()),
|
||||
])
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let mut fresh = ClientState::default();
|
||||
fresh.bootstrap(snapshot);
|
||||
|
||||
assert_eq!(fresh.grid, live.grid, "grid must survive the compaction");
|
||||
assert_eq!(fresh.categories, live.categories);
|
||||
assert_eq!(fresh.formulas, live.formulas);
|
||||
assert_eq!(fresh.views, live.views);
|
||||
assert_eq!(fresh.tiles, live.tiles);
|
||||
assert_eq!(fresh.view.cursor().selected, (1, 1));
|
||||
}
|
||||
|
||||
/// A bootstrap arriving mid-stream is a rebase: the old caches must not
|
||||
/// show through it (gotcha 39 — the client's cursor map is keyed by the
|
||||
/// outgoing document's view names and only a fresh snapshot replaces it).
|
||||
#[test]
|
||||
fn a_mid_stream_bootstrap_discards_what_came_before() {
|
||||
let mut state = folded(&grid_projections());
|
||||
state.bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![],
|
||||
});
|
||||
assert_eq!(
|
||||
state.grid,
|
||||
GridCache::default(),
|
||||
"the previous document's grid must not survive a rebase"
|
||||
);
|
||||
assert!(state.view.cursors.is_empty());
|
||||
}
|
||||
|
||||
/// The two halves do not leak into each other: `reduce_view` folds only
|
||||
/// the view slice, and a projection never reaches it.
|
||||
#[test]
|
||||
fn a_projection_and_a_view_command_touch_disjoint_halves() {
|
||||
let mut state = folded(&grid_projections());
|
||||
let view_before = format!("{:?}", state.view);
|
||||
state.fold(&Command::Project(Projection::Formulas(vec![
|
||||
"x = 1".into(),
|
||||
])));
|
||||
assert_eq!(
|
||||
format!("{:?}", state.view),
|
||||
view_before,
|
||||
"a projection is not the view slice's business"
|
||||
);
|
||||
|
||||
let grid_before = state.grid.clone();
|
||||
state.fold(&Command::SetStatus("saved".into()));
|
||||
assert_eq!(state.grid, grid_before, "a view command reaches no cache");
|
||||
assert_eq!(state.view.status_msg, "saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
|
||||
let mut state = ClientState::default();
|
||||
assert_eq!(state.revision, 0);
|
||||
state.fold(&Command::SetStatus("a".into()));
|
||||
state.fold(&Command::Project(Projection::Formulas(vec![])));
|
||||
assert_eq!(state.revision, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! The two envelopes this client puts on the socket, and the one it takes off.
|
||||
//!
|
||||
//! `improvise-protocol` owns the *vocabulary* ([`Command`], [`Projection`],
|
||||
//! [`Snapshot`]); what it does not yet own is the framing that says which of
|
||||
//! them a given JSON blob is. These two enums are that framing, and they live
|
||||
//! here only because the protocol crate does not name them yet — they are
|
||||
//! candidates to move, not a second vocabulary (see the crate docs).
|
||||
//!
|
||||
//! [`ClientMessage::Key`] is the interesting one. It exists because a
|
||||
//! keypress is **not** a [`Command`]: a binding names an intent resolved
|
||||
//! against a `CmdContext` the client does not hold, so most keys travel
|
||||
//! unresolved and the host resolves them (gotcha 38, improvise-gxi). It
|
||||
//! carries `(KeyCode, KeyModifiers)` rather than a `KeyEvent` because
|
||||
//! `KeyEvent` is not `Serialize` — the same pair
|
||||
//! `Command::SetTransientPrefix` already puts on the wire.
|
||||
|
||||
use improvise_protocol::{Command, KeyCode, KeyEvent, KeyModifiers, Snapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Client → server.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
/// An intent this client resolved by itself and has already folded. The
|
||||
/// host folds it into its mirror so the two stay in step.
|
||||
Command(Command),
|
||||
/// A keypress this client could not resolve. The host runs it through the
|
||||
/// keymap against the authoritative `App`.
|
||||
Key {
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClientMessage {
|
||||
pub fn key(ev: KeyEvent) -> Self {
|
||||
Self::Key {
|
||||
code: ev.code,
|
||||
modifiers: ev.modifiers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
///
|
||||
/// [`ServerMessage::Bootstrap`] is the only non-event: it replaces the whole
|
||||
/// client state. Everything else is one fold step. A `Bootstrap` arriving
|
||||
/// mid-stream is a *rebase* (gotcha 39) — the document was swapped and the
|
||||
/// cursor map, keyed by the outgoing document's view names, is no longer
|
||||
/// meaningful.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ServerMessage {
|
||||
/// Boxed: a `Snapshot` is an order of magnitude larger than a `Command`,
|
||||
/// and `Command` is every message after the first. Serde sees through the
|
||||
/// box, so the JSON is unchanged.
|
||||
Bootstrap(Box<Snapshot>),
|
||||
Command(Command),
|
||||
}
|
||||
|
||||
impl ServerMessage {
|
||||
pub fn bootstrap(snapshot: Snapshot) -> Self {
|
||||
Self::Bootstrap(Box::new(snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use improvise_protocol::{GridUpdate, Projection, ViewState};
|
||||
|
||||
#[test]
|
||||
fn a_client_message_round_trips_through_json() {
|
||||
for msg in [
|
||||
ClientMessage::Command(Command::SetSelected { row: 2, col: 3 }),
|
||||
ClientMessage::key(KeyEvent::new(
|
||||
KeyCode::Char('x'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
)),
|
||||
ClientMessage::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
] {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ClientMessage>(&json).unwrap(),
|
||||
msg,
|
||||
"round trip changed {msg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_message_round_trips_through_json() {
|
||||
for msg in [
|
||||
ServerMessage::Command(Command::Project(Projection::Grid(GridUpdate::RowLabels(
|
||||
vec!["North".into()],
|
||||
)))),
|
||||
ServerMessage::bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![Projection::Formulas(vec!["Profit = Revenue".into()])],
|
||||
}),
|
||||
] {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ServerMessage>(&json).unwrap(),
|
||||
msg,
|
||||
"round trip changed {msg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The client's local key resolution, held against the host that owns the
|
||||
//! keymap.
|
||||
//!
|
||||
//! `src/keys.rs` decides a small set of keypresses without the document. The
|
||||
//! claim that makes that safe is not "the rule looks the same" but **the two
|
||||
//! ends land in the same `ViewState`**, and that is checkable: the host's
|
||||
//! `reduce_key` runs the real keymap, registry and effect pipeline against an
|
||||
//! authoritative `App`, and `ViewState` is the protocol's own type on both
|
||||
//! sides. So every local outcome here is driven through both and compared.
|
||||
//!
|
||||
//! This is why `improvise-command` is a dev-dependency. Dev-dependencies are
|
||||
//! not built for `--target wasm32-unknown-unknown`, so nothing the host owns
|
||||
//! reaches the browser bundle.
|
||||
|
||||
use improvise_command::ui::app::App;
|
||||
use improvise_command::ui::reduce::reduce_key;
|
||||
use improvise_command::view::Axis;
|
||||
use improvise_command::workbook::Workbook;
|
||||
use improvise_protocol::{AppMode, KeyCode, KeyEvent, KeyModifiers, ViewState, reduce_view};
|
||||
use improvise_wasm_client::{BrowserKey, ClientMessage, ClientState, KeyOutcome, resolve};
|
||||
|
||||
/// Two rows on the row axis, so a `Down` has somewhere to go — a one-item
|
||||
/// fixture clamps and cannot tell "the host resolved it" from "nothing
|
||||
/// happened".
|
||||
fn host() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
App::new(wb, None)
|
||||
}
|
||||
|
||||
fn text_entry_modes() -> Vec<AppMode> {
|
||||
vec![
|
||||
AppMode::editing(),
|
||||
AppMode::records_editing(),
|
||||
AppMode::formula_edit(),
|
||||
AppMode::command_mode(),
|
||||
AppMode::category_add(),
|
||||
AppMode::item_add("Region".into()),
|
||||
AppMode::export_prompt(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The headline claim: for every keypress the client answers itself, the host
|
||||
/// answering the same keypress lands on the same buffers.
|
||||
///
|
||||
/// Driving one mode would say nothing about the others
|
||||
/// (`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`),
|
||||
/// so every text-entry mode is driven, and each with a non-empty buffer so an
|
||||
/// append that silently *replaced* the buffer would be visible.
|
||||
#[test]
|
||||
fn a_locally_typed_character_lands_where_the_host_would_put_it() {
|
||||
for mode in text_entry_modes() {
|
||||
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
||||
|
||||
let mut app = host();
|
||||
app.view_state.mode = mode.clone();
|
||||
app.view_state.buffers.insert(buffer.clone(), "Reg".into());
|
||||
|
||||
let mut client = ClientState {
|
||||
view: app.view_state.clone(),
|
||||
..ClientState::default()
|
||||
};
|
||||
|
||||
let event = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||
let KeyOutcome::Local(cmd) = resolve(&client.view, event) else {
|
||||
panic!("{} should type locally", mode.label());
|
||||
};
|
||||
client.fold(&cmd);
|
||||
reduce_key(&mut app, event);
|
||||
|
||||
assert_eq!(
|
||||
client.view.buffers,
|
||||
app.view_state.buffers,
|
||||
"{}: client and host disagree about the buffers after typing",
|
||||
mode.label()
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.buffers.get(&buffer).map(String::as_str),
|
||||
Some("Regi"),
|
||||
"{}: the character must be appended, not replace the buffer",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same, for the pop. CommandMode is excluded because it does not resolve
|
||||
/// locally at all — the next test is about that.
|
||||
#[test]
|
||||
fn a_locally_popped_character_lands_where_the_host_would_put_it() {
|
||||
for mode in text_entry_modes() {
|
||||
if matches!(mode, AppMode::CommandMode { .. }) {
|
||||
continue;
|
||||
}
|
||||
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
||||
|
||||
let mut app = host();
|
||||
app.view_state.mode = mode.clone();
|
||||
app.view_state.buffers.insert(buffer.clone(), "Regi".into());
|
||||
|
||||
let mut client = ClientState {
|
||||
view: app.view_state.clone(),
|
||||
..ClientState::default()
|
||||
};
|
||||
|
||||
let event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
|
||||
let KeyOutcome::Local(cmd) = resolve(&client.view, event) else {
|
||||
panic!("{} should pop locally", mode.label());
|
||||
};
|
||||
client.fold(&cmd);
|
||||
reduce_key(&mut app, event);
|
||||
|
||||
assert_eq!(
|
||||
client.view.buffers,
|
||||
app.view_state.buffers,
|
||||
"{}: client and host disagree about the buffers after a pop",
|
||||
mode.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why CommandMode's Backspace is upstream, demonstrated rather than
|
||||
/// asserted: on the host it *leaves the mode* when the buffer is empty
|
||||
/// (`TextEntry::rebind`, gotcha 11). A client that popped it locally would sit
|
||||
/// in `:` after the host had left it.
|
||||
#[test]
|
||||
fn command_mode_backspace_on_an_empty_buffer_changes_the_mode_on_the_host() {
|
||||
let mut app = host();
|
||||
app.view_state.mode = AppMode::command_mode();
|
||||
app.view_state
|
||||
.buffers
|
||||
.insert("command".into(), String::new());
|
||||
|
||||
let event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
|
||||
assert_eq!(
|
||||
resolve(&app.view_state.clone(), event),
|
||||
KeyOutcome::Upstream(event),
|
||||
"the client must not answer this key"
|
||||
);
|
||||
|
||||
reduce_key(&mut app, event);
|
||||
assert!(
|
||||
matches!(app.view_state.mode, AppMode::Normal),
|
||||
"the host leaves command mode on an empty backspace, got {:?}",
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
/// An unresolvable key must reach the host as a keypress, and what the host
|
||||
/// then does to *its* copy of this client's view slice is what the client
|
||||
/// will be told. Pinned here as the shape the transport owes: the message
|
||||
/// carries the code and modifiers, and they survive the round trip.
|
||||
#[test]
|
||||
fn a_forwarded_key_reaches_the_host_unchanged() {
|
||||
let mut client = ClientState::default();
|
||||
let browser = BrowserKey::new("ArrowDown");
|
||||
let event = browser.to_key_event().unwrap();
|
||||
assert_eq!(resolve(&client.view, event), KeyOutcome::Upstream(event));
|
||||
|
||||
let wire = serde_json::to_string(&ClientMessage::key(event)).unwrap();
|
||||
let ClientMessage::Key { code, modifiers } = serde_json::from_str(&wire).unwrap() else {
|
||||
panic!("a forwarded key must stay a key");
|
||||
};
|
||||
|
||||
let mut app = host();
|
||||
reduce_key(&mut app, KeyEvent::new(code, modifiers));
|
||||
assert_eq!(
|
||||
app.view_state.cursor().selected,
|
||||
(1, 0),
|
||||
"the host resolved the arrow against the document the client does not have"
|
||||
);
|
||||
|
||||
// And the client catches up only by folding what it is told.
|
||||
assert_eq!(client.view.cursor().selected, (0, 0));
|
||||
client.fold(&improvise_protocol::Command::SetSelected { row: 1, col: 0 });
|
||||
assert_eq!(client.view.cursor().selected, (1, 0));
|
||||
}
|
||||
|
||||
/// `reduce_view` is the shared fold, and the client uses the protocol's copy
|
||||
/// of it rather than a second implementation — so a command the host folds
|
||||
/// into its mirror moves this client identically.
|
||||
#[test]
|
||||
fn the_client_folds_a_command_exactly_as_the_shared_reducer_does() {
|
||||
let cmds = [
|
||||
improvise_protocol::Command::SwitchView("Second".into()),
|
||||
improvise_protocol::Command::SetSelected { row: 3, col: 4 },
|
||||
improvise_protocol::Command::SetStatus("saved".into()),
|
||||
improvise_protocol::Command::ToggleCatExpand("Region".into()),
|
||||
];
|
||||
|
||||
let mut client = ClientState::default();
|
||||
let mut mirror = ViewState::default();
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
reduce_view(&mut mirror, cmd);
|
||||
}
|
||||
assert_eq!(format!("{:?}", client.view), format!("{mirror:?}"));
|
||||
}
|
||||
Reference in New Issue
Block a user