Merge: one keymap table, shared by the TUI and the browser (improvise-bpq2 successor)

The browser's hand-written key classifier was a second spelling of
default_keymaps(), held honest by a lockstep test over a hand-written list --
doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)'s
exact shape. So the TABLE moved to improvise-protocol and improvise-command
keeps only what needs a CmdRegistry. Evidence is binding_dump_diff, not the
suite: 4021 resolved (mode, key, modifier) lookups over sixteen modes,
BYTE-IDENTICAL, with the dump kept as an opt-in test and proved
discriminating by renaming one binding.

The boundary that did NOT move is the better one: key -> intent is shared,
intent -> Command stays client-side, and the named list of what still needs
the host is now a list of COMMAND NAMES rather than keys -- so a rebinding no
longer invalidates it.

AND A LOCKSTEP TEST FOUND A LIVE BUG, BY A COMMA. CellDisplay::text has been
through format_f64; App::display_value_at_cursor has not. Seeding an editor
from the drawn text and committing turns a stored 1200 into the string
'1,200' -- display rounding fed back into the model, which design-principles
4 forbids outright. CellDisplay::edit_text puts the unformatted value on the
wire, and it repairs web/app.js's click-to-edit, which had been seeding its
input from td.textContent since it was written. That bug is on the live page
today for any value with a thousands separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 21:36:47 -07:00
14 changed files with 2422 additions and 1260 deletions
@@ -4,23 +4,11 @@ use crate::ui::effect::Panel;
/// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill` /// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill`
/// keymap bindings) into an `AppMode`. /// keymap bindings) into an `AppMode`.
/// The table itself is `AppMode::from_name`, beside `AppMode` in the
/// protocol crate: the spellings are keymap vocabulary and the keymap is
/// shared, so the browser client decodes `enter-mode` from the same list.
fn parse_mode_name(s: &str) -> Result<AppMode, String> { fn parse_mode_name(s: &str) -> Result<AppMode, String> {
match s { AppMode::from_name(s).ok_or_else(|| format!("Unknown mode: {s}"))
"normal" => Ok(AppMode::Normal),
"help" => Ok(AppMode::Help),
"formula-panel" => Ok(AppMode::FormulaPanel),
"category-panel" => Ok(AppMode::CategoryPanel),
"view-panel" => Ok(AppMode::ViewPanel),
"tile-select" => Ok(AppMode::TileSelect),
"command" => Ok(AppMode::command_mode()),
"category-add" => Ok(AppMode::category_add()),
"editing" => Ok(AppMode::editing()),
"records-normal" => Ok(AppMode::RecordsNormal),
"records-editing" => Ok(AppMode::records_editing()),
"formula-edit" => Ok(AppMode::formula_edit()),
"export-prompt" => Ok(AppMode::export_prompt()),
other => Err(format!("Unknown mode: {other}")),
}
} }
use super::cell::*; use super::cell::*;
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -470,7 +470,14 @@ impl App {
.unwrap_or_default() .unwrap_or_default()
} }
fn display_value_of(&self, key: &CellKey) -> String { /// The value an editor opened on `key` starts with — unformatted, and
/// so **not** `CellDisplay::text` (design-principles §4: display
/// rounding is view-only and must not feed back into the model).
///
/// Crate-visible so `project::cell_display` can put it on the wire as
/// `CellDisplay::edit_text`; a browser that un-formatted the drawn text
/// instead would be a second derivation, and a lossy one.
pub(crate) fn display_value_of(&self, key: &CellKey) -> String {
match crate::view::synthetic_record_info(key) { match crate::view::synthetic_record_info(key) {
Some((idx, col)) => self Some((idx, col)) => self
.layout .layout
@@ -265,6 +265,7 @@ fn cell_display(app: &App, row: usize, col: usize, comma: bool, decimals: u8) ->
let text = layout.records_display(row, col).unwrap_or_default(); let text = layout.records_display(row, col).unwrap_or_default();
let numeric = !text.is_empty() && text.parse::<f64>().is_ok(); let numeric = !text.is_empty() && text.parse::<f64>().is_ok();
return CellDisplay { return CellDisplay {
edit_text: Some(text.clone()),
text, text,
numeric, numeric,
error: false, error: false,
@@ -282,6 +283,14 @@ fn cell_display(app: &App, row: usize, col: usize, comma: bool, decimals: u8) ->
numeric: matches!(value, Some(CellValue::Number(_))), numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))), error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(app, row, col), id: cell_id(app, row, col),
// The *unformatted* value, which is what the TUI's own
// `display_value_at_cursor` seeds its edit buffer from. Projecting it
// rather than letting a client un-format `text` is the rule from
// gotcha 47: where a client cannot compose the command, enrich the
// projection.
edit_text: layout
.cell_key(row, col)
.map(|key| app.display_value_of(&key)),
} }
} }
+939
View File
@@ -0,0 +1,939 @@
//! The keymap: which key means what, in which mode.
//!
//! # One table, both ends
//!
//! This used to live in `improvise-command`, where only the terminal could
//! reach it, and the browser client carried a hand-written classifier that
//! answered the same question for the subset it could resolve. That is two
//! definitions of one vocabulary kept in step by a lockstep test over a
//! hand-written list of keys — and
//! `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
//! says such a pair is one definition waiting to be made. It is made here.
//!
//! The move is the one gotcha 13 already anticipated:
//! `ViewState::transient_prefix` stores a prefix as an *address* rather than
//! an `Arc<Keymap>` precisely so that `ViewState` could stay free of
//! `improvise-command` types while the keymap came the other way.
//!
//! # The boundary that did not move
//!
//! A lookup yields a command **name and arguments**. Turning that into
//! something that runs needs a `CmdContext` — a `Model`, a `GridLayout`, an
//! `App` — which a thin client does not have, so *intent → mutation* stays
//! where it was: `improvise-command` resolves a name through its
//! `CmdRegistry` into `Effect`s, and a browser client resolves the subset it
//! can compose into `Command`s.
//!
//! That is a better seam than the one it replaces. The drift risk used to be
//! "which key does what" — 170 bindings, no direct test, invisible when
//! wrong. It is now "which command names can I execute", which is a list.
//!
//! What stayed in `improvise-command`, and why each had to:
//!
//! - `Keymap::dispatch` and `KeymapSet`, which pair this table with a
//! `CmdRegistry` and return `Box<dyn Effect>`.
//! - `SetTransientPrefix`, which is an `Effect` — it writes
//! `ViewState::transient_prefix`, and `Effect` is the command layer's trait.
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use crate::key::{KeyCode, KeyModifiers};
use crate::{AppMode, Panel};
/// Format a KeyCode as a short human-readable label for which-key display.
fn format_key_label(code: &KeyCode) -> String {
match code {
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Esc => "Esc".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::BackTab => "S-Tab".to_string(),
KeyCode::Backspace => "BS".to_string(),
KeyCode::Delete => "Del".to_string(),
KeyCode::Left => "←".to_string(),
KeyCode::Right => "→".to_string(),
KeyCode::Up => "↑".to_string(),
KeyCode::Down => "↓".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PgUp".to_string(),
KeyCode::PageDown => "PgDn".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => format!("{code:?}"),
}
}
// `cmd` module imported for `default_registry()` in default_keymaps()
/// A key pattern that can be matched against a KeyEvent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyPattern {
/// Single key with modifiers
Key(KeyCode, KeyModifiers),
/// Matches any Char key (for text-entry modes).
AnyChar,
/// Matches any key at all (lowest priority fallback).
Any,
}
/// Identifies which mode a binding applies to.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModeKey {
Normal,
Help,
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect,
Editing,
FormulaEdit,
CategoryAdd,
ItemAdd,
ExportPrompt,
CommandMode,
SearchMode,
ImportWizard,
RecordsNormal,
RecordsEditing,
}
impl ModeKey {
pub fn from_app_mode(mode: &AppMode, search_mode: bool) -> Option<Self> {
match mode {
AppMode::Normal if search_mode => Some(ModeKey::SearchMode),
AppMode::Normal => Some(ModeKey::Normal),
AppMode::Help => Some(ModeKey::Help),
AppMode::FormulaPanel => Some(ModeKey::FormulaPanel),
AppMode::CategoryPanel => Some(ModeKey::CategoryPanel),
AppMode::ViewPanel => Some(ModeKey::ViewPanel),
AppMode::TileSelect => Some(ModeKey::TileSelect),
AppMode::Editing { .. } => Some(ModeKey::Editing),
AppMode::FormulaEdit { .. } => Some(ModeKey::FormulaEdit),
AppMode::CategoryAdd { .. } => Some(ModeKey::CategoryAdd),
AppMode::ItemAdd { .. } => Some(ModeKey::ItemAdd),
AppMode::ExportPrompt { .. } => Some(ModeKey::ExportPrompt),
AppMode::CommandMode { .. } => Some(ModeKey::CommandMode),
AppMode::ImportWizard => Some(ModeKey::ImportWizard),
AppMode::RecordsNormal => Some(ModeKey::RecordsNormal),
AppMode::RecordsEditing { .. } => Some(ModeKey::RecordsEditing),
// Exhaustive on purpose: a new AppMode without a keymap silently
// swallows every keypress, so the compiler must flag it here.
AppMode::Quit => None,
}
}
}
/// What a key binding resolves to.
#[derive(Debug, Clone)]
pub enum Binding {
/// A command name + arguments, looked up in the registry at dispatch time.
Cmd {
name: &'static str,
args: Vec<String>,
},
/// A prefix sub-keymap (Emacs-style).
Prefix(Arc<Keymap>),
/// A sequence of commands executed in order, concatenating their effects.
Sequence(Vec<(&'static str, Vec<String>)>),
}
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
/// Supports Emacs-style parent keymap inheritance: if a key is not found in this
/// keymap, lookup falls through to the parent.
#[derive(Default)]
pub struct Keymap {
bindings: HashMap<KeyPattern, Binding>,
parent: Option<Arc<Keymap>>,
}
impl fmt::Debug for Keymap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Keymap")
.field("binding_count", &self.bindings.len())
.finish()
}
}
impl Keymap {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
parent: None,
}
}
/// Create a keymap that inherits from a parent. Keys not found here
/// fall through to the parent (Emacs-style keymap inheritance).
pub fn with_parent(parent: Arc<Keymap>) -> Self {
Self {
bindings: HashMap::new(),
parent: Some(parent),
}
}
/// Bind a key to a command name (no args).
pub fn bind(&mut self, key: KeyCode, mods: KeyModifiers, name: &'static str) {
self.bindings.insert(
KeyPattern::Key(key, mods),
Binding::Cmd { name, args: vec![] },
);
}
/// Bind a key to a command name with arguments.
pub fn bind_args(
&mut self,
key: KeyCode,
mods: KeyModifiers,
name: &'static str,
args: Vec<String>,
) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Cmd { name, args });
}
/// Bind a prefix key that activates a sub-keymap.
pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc<Keymap>) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
}
/// Bind a key to a sequence of commands (executed in order).
pub fn bind_seq(
&mut self,
key: KeyCode,
mods: KeyModifiers,
steps: Vec<(&'static str, Vec<String>)>,
) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Sequence(steps));
}
/// Bind a catch-all for any Char key.
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
self.bindings
.insert(KeyPattern::AnyChar, Binding::Cmd { name, args });
}
/// Bind a catch-all for any key at all.
pub fn bind_any(&mut self, name: &'static str) {
self.bindings
.insert(KeyPattern::Any, Binding::Cmd { name, args: vec![] });
}
/// Return human-readable hints for all concrete bindings in this keymap.
/// Used by the which-key popup to show available completions after a prefix key.
pub fn binding_hints(&self) -> Vec<(String, &'static str)> {
let mut hints: Vec<(String, &'static str)> = self
.bindings
.iter()
.filter_map(|(pattern, binding)| {
let label = match pattern {
KeyPattern::Key(code, _) => format_key_label(code),
KeyPattern::AnyChar | KeyPattern::Any => return None,
};
let name = match binding {
Binding::Cmd { name, .. } => *name,
Binding::Prefix(_) => return None,
Binding::Sequence(steps) => {
if let Some((name, _)) = steps.first() {
*name
} else {
return None;
}
}
};
Some((label, name))
})
.collect();
hints.sort_by(|a, b| a.0.cmp(&b.0));
hints
}
/// This keymap's own bindings, not counting anything inherited.
///
/// For tests that must walk the table rather than probe it — the
/// flatness check on prefixes, and the "which modes bind AnyChar" walk.
pub fn bindings(&self) -> &HashMap<KeyPattern, Binding> {
&self.bindings
}
/// Look up the binding for a key in this keymap's own bindings.
fn lookup_local(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
.get(&KeyPattern::Key(key, mods))
.or_else(|| {
// Retry Char keys without modifiers (shift is implicit in the char)
if matches!(key, KeyCode::Char(_)) && mods != KeyModifiers::NONE {
self.bindings.get(&KeyPattern::Key(key, KeyModifiers::NONE))
} else {
None
}
})
.or_else(|| {
if matches!(key, KeyCode::Char(_)) {
self.bindings.get(&KeyPattern::AnyChar)
} else {
None
}
})
.or_else(|| self.bindings.get(&KeyPattern::Any))
}
/// Look up the binding for a key, falling through to parent keymaps.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.lookup_local(key, mods)
.or_else(|| self.parent.as_ref().and_then(|p| p.lookup(key, mods)))
}
}
/// Builder for a minibuffer mode's keymap.
///
/// Every text-entry mode has the same spine: characters append to one named
/// buffer, Backspace pops from it, and Esc clears it before leaving for some
/// other mode. Only the buffer name, the exit mode and the commit keys differ.
/// Building them through here means a new text mode cannot accidentally type
/// into one buffer while clearing another, or drop the clear entirely — which
/// would leave the next entry pre-filled with the last one.
///
/// `SearchMode` deliberately does not use this: it has no commit key, and its
/// query is owned by `exit-search-mode` rather than by `clear-buffer`.
pub struct TextEntry {
buffer: String,
km: Keymap,
}
impl TextEntry {
/// The keymap for `mode`, returning to `exit` on Esc. The buffer comes
/// off the mode's own `MinibufferConfig`, so the buffer the prompt
/// describes and the buffer the keys edit are the same string by
/// construction rather than by agreement (improvise-k8h).
pub fn new(mode: &AppMode, exit: &'static str) -> Self {
Self::build(buffer_of(mode).to_string(), exit, Keymap::new())
}
/// As [`TextEntry::new`], but inheriting unbound keys from `parent`.
pub fn with_parent(mode: &AppMode, exit: &'static str, parent: Arc<Keymap>) -> Self {
Self::build(
buffer_of(mode).to_string(),
exit,
Keymap::with_parent(parent),
)
}
fn build(buffer: String, exit: &'static str, mut km: Keymap) -> Self {
let none = KeyModifiers::NONE;
km.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec![buffer.clone()]),
("enter-mode", vec![exit.into()]),
],
);
km.bind_args(KeyCode::Backspace, none, "pop-char", vec![buffer.clone()]);
km.bind_any_char("append-char", vec![buffer.clone()]);
Self { buffer, km }
}
/// Bind `key` to "run `cmd`, then clear the buffer". The two halves are
/// welded together here so no caller can bind a commit without its clear.
pub fn commit(mut self, key: KeyCode, cmd: &'static str, args: Vec<String>) -> Self {
self.km.bind_seq(
key,
KeyModifiers::NONE,
vec![(cmd, args), ("clear-buffer", vec![self.buffer.clone()])],
);
self
}
/// Replace a spine binding — used only by CommandMode, whose Backspace
/// exits the mode on an empty buffer instead of popping a character.
pub fn rebind(mut self, key: KeyCode, cmd: &'static str) -> Self {
self.km.bind(key, KeyModifiers::NONE, cmd);
self
}
pub fn finish(self) -> Keymap {
self.km
}
}
/// Builder for a side panel's keymap.
///
/// The three panels share a spine: Esc and Tab leave for Normal, the arrows
/// and j/k move that panel's cursor, every panel's toggle key is bound so
/// focus can move between them, and `:` opens command mode. Only the keys
/// that act on the panel's contents differ — and they differ in shape, not
/// just in command name (Category's `n` takes an argument, View has no `a`,
/// Formula has no Enter), so they arrive through chained methods rather than
/// a fixed-arity signature (improvise-4pg).
pub struct PanelKeymap {
km: Keymap,
}
impl PanelKeymap {
/// The spine every panel shares, with the cursor keys pointed at `panel`.
pub fn new(panel: Panel) -> Self {
let none = KeyModifiers::NONE;
let mut km = Keymap::new();
km.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
km.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for (keys, delta) in [
([KeyCode::Up, KeyCode::Char('k')], "-1"),
([KeyCode::Down, KeyCode::Char('j')], "1"),
] {
for key in keys {
km.bind_args(
key,
none,
"move-panel-cursor",
vec![panel.name().into(), delta.into()],
);
}
}
bind_panel_toggles(&mut km);
km.bind_args(
KeyCode::Char(':'),
none,
"enter-mode",
vec!["command".into()],
);
Self { km }
}
/// Bind a key that acts on this panel's contents.
pub fn bind(mut self, key: KeyCode, cmd: &'static str) -> Self {
self.km.bind(key, KeyModifiers::NONE, cmd);
self
}
/// As [`PanelKeymap::bind`], for a command that takes arguments.
pub fn bind_args(mut self, key: KeyCode, cmd: &'static str, args: Vec<String>) -> Self {
self.km.bind_args(key, KeyModifiers::NONE, cmd, args);
self
}
/// `d` and Delete both remove whatever the cursor is on. Binding them
/// together means a panel cannot offer one without the other.
pub fn delete(self, cmd: &'static str) -> Self {
self.bind(KeyCode::Char('d'), cmd)
.bind(KeyCode::Delete, cmd)
}
pub fn finish(self) -> Keymap {
self.km
}
}
/// Bind every panel's toggle key. Used by the panel keymaps and by Normal,
/// so a new panel reaches all four with one `Panel::ALL` entry.
fn bind_panel_toggles(km: &mut Keymap) {
for panel in Panel::ALL {
km.bind_args(
KeyCode::Char(panel.key()),
KeyModifiers::NONE,
"toggle-panel-and-focus",
vec![panel.name().into()],
);
}
}
/// The buffer a text-entry mode types into. Every caller is one of the seven
/// modes that carry a `MinibufferConfig`; a mode without one has no minibuffer
/// to build a keymap for.
fn buffer_of(mode: &AppMode) -> &str {
&mode
.minibuffer()
.unwrap_or_else(|| panic!("{} has no minibuffer to type into", mode.label()))
.buffer_key
}
/// Maps modes to their root keymaps.
///
/// The table alone. `improvise-command`'s `KeymapSet` wraps one of these with
/// a `CmdRegistry` to get from a bound name to an `Effect`; a browser client
/// wraps the same table with its own much smaller resolver. Neither owns the
/// bindings.
#[derive(Debug, Default)]
pub struct Keymaps {
mode_maps: HashMap<ModeKey, Arc<Keymap>>,
}
impl Keymaps {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, mode: ModeKey, keymap: Arc<Keymap>) {
self.mode_maps.insert(mode, keymap);
}
/// The root keymap for a mode.
pub fn get(&self, mode: &ModeKey) -> Option<&Arc<Keymap>> {
self.mode_maps.get(mode)
}
/// Every mode that has a keymap, for tests that must walk all of them.
pub fn modes(&self) -> impl Iterator<Item = &ModeKey> {
self.mode_maps.keys()
}
pub fn iter(&self) -> impl Iterator<Item = (&ModeKey, &Arc<Keymap>)> {
self.mode_maps.iter()
}
pub fn contains_key(&self, mode: &ModeKey) -> bool {
self.mode_maps.contains_key(mode)
}
/// The binding `mode` gives `key`, walking parent keymaps.
pub fn lookup(&self, mode: &ModeKey, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.mode_maps.get(mode)?.lookup(key, mods)
}
/// The sub-keymap a prefix key opens, in `mode`. `None` when the mode
/// has no such binding, or when the binding is not a prefix.
///
/// Resolution deliberately goes through `lookup`, which walks parent
/// keymaps: `RecordsNormal` has no prefixes of its own and reaches
/// Normal's through inheritance.
pub fn prefix_keymap(
&self,
mode: &ModeKey,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Arc<Keymap>> {
match self.mode_maps.get(mode)?.lookup(key, mods)? {
Binding::Prefix(sub) => Some(sub.clone()),
Binding::Cmd { .. } | Binding::Sequence(_) => None,
}
}
/// Build the default keymap set with all bindings.
pub fn default_keymaps() -> Self {
let mut set = Self::new();
let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
// ── Normal mode ──────────────────────────────────────────────────
let mut normal = Keymap::new();
// Navigation
for (key, dr, dc) in [
(KeyCode::Up, -1, 0),
(KeyCode::Down, 1, 0),
(KeyCode::Left, 0, -1),
(KeyCode::Right, 0, 1),
] {
normal.bind_args(
key,
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
for (ch, dr, dc) in [('k', -1, 0), ('j', 1, 0), ('h', 0, -1), ('l', 0, 1)] {
normal.bind_args(
KeyCode::Char(ch),
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
// Jump to boundaries
normal.bind(KeyCode::Char('G'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), none, "jump-last-col");
normal.bind(KeyCode::Home, none, "jump-first-col");
normal.bind(KeyCode::End, none, "jump-last-col");
// Scroll
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
normal.bind_args(KeyCode::PageDown, none, "page-scroll", vec!["1".into()]);
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".into()]);
// Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell");
normal.bind(KeyCode::Char('p'), none, "paste");
// View
normal.bind(KeyCode::Char('t'), none, "transpose");
// Mode changes
normal.bind(KeyCode::Char('q'), ctrl, "force-quit");
normal.bind_args(
KeyCode::Char(':'),
none,
"enter-mode",
vec!["command".into()],
);
normal.bind(KeyCode::Char('/'), none, "search");
normal.bind(KeyCode::Char('s'), ctrl, "save");
normal.bind(KeyCode::F(1), none, "enter-mode");
normal.bind_args(KeyCode::F(1), none, "enter-mode", vec!["help".into()]);
normal.bind_args(KeyCode::Char('?'), none, "enter-mode", vec!["help".into()]);
// Panel toggles — the same set the panel keymaps bind, so a new
// panel reaches Normal and all three panels at once.
bind_panel_toggles(&mut normal);
normal.bind_args(
KeyCode::Char('f'),
ctrl,
"toggle-panel-visibility",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('c'),
ctrl,
"toggle-panel-visibility",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('v'),
ctrl,
"toggle-panel-visibility",
vec!["view".into()],
);
normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
// Editing entry — i/a drill into aggregated cells, else edit.
// The mode arg controls which editing mode is entered; records-normal
// overrides these to "records-editing" via its own bindings.
normal.bind_args(
KeyCode::Char('i'),
none,
"edit-or-drill",
vec!["editing".into()],
);
normal.bind_args(
KeyCode::Char('a'),
none,
"edit-or-drill",
vec!["editing".into()],
);
normal.bind(KeyCode::Enter, none, "enter-advance");
normal.bind_args(
KeyCode::Char('e'),
ctrl,
"enter-mode",
vec!["export-prompt".into()],
);
// Search / category add
normal.bind_args(
KeyCode::Char('n'),
none,
"search-navigate",
vec!["forward".into()],
);
normal.bind(KeyCode::Char('N'), none, "search-or-category-add");
// Page navigation
normal.bind(KeyCode::Char(']'), none, "page-next");
normal.bind(KeyCode::Char('['), none, "page-prev");
// Group / hide
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
// Drill into aggregated cell / view history
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back");
// Records mode toggle and prune toggle
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
normal.bind(KeyCode::Char('P'), none, "toggle-prune-empty");
// Tile select
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
normal.bind(KeyCode::Left, ctrl, "enter-tile-select");
normal.bind(KeyCode::Right, ctrl, "enter-tile-select");
normal.bind(KeyCode::Up, ctrl, "enter-tile-select");
normal.bind(KeyCode::Down, ctrl, "enter-tile-select");
// Prefix keys
let mut g_map = Keymap::new();
g_map.bind(KeyCode::Char('g'), none, "jump-first-row");
g_map.bind(KeyCode::Char('z'), none, "toggle-col-group-under-cursor");
normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map));
let mut y_map = Keymap::new();
y_map.bind(KeyCode::Char('y'), none, "yank");
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
let mut z_map = Keymap::new();
z_map.bind(KeyCode::Char('Z'), none, "wq");
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
let normal = Arc::new(normal);
set.insert(ModeKey::Normal, normal.clone());
// ── Records normal mode (inherits from normal) ────────────────────
let mut rn = Keymap::with_parent(normal);
// Override i/a so the edit branch produces records-editing mode
// instead of inheriting the normal-mode "editing" arg.
rn.bind_args(
KeyCode::Char('i'),
none,
"edit-or-drill",
vec!["records-editing".into()],
);
rn.bind_args(
KeyCode::Char('a'),
none,
"edit-or-drill",
vec!["records-editing".into()],
);
rn.bind_seq(
KeyCode::Char('o'),
none,
vec![
("add-record-row", vec![]),
("enter-edit-at-cursor", vec!["records-editing".into()]),
],
);
set.insert(ModeKey::RecordsNormal, Arc::new(rn));
// ── Help mode ────────────────────────────────────────────────────
let mut help = Keymap::new();
help.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
help.bind_args(
KeyCode::Char('q'),
none,
"enter-mode",
vec!["normal".into()],
);
// Allow entering command mode from Help (important on empty-model launch)
help.bind_args(
KeyCode::Char(':'),
none,
"enter-mode",
vec!["command".into()],
);
// Page navigation
help.bind(KeyCode::Right, none, "help-page-next");
help.bind(KeyCode::Char('l'), none, "help-page-next");
help.bind(KeyCode::Char('n'), none, "help-page-next");
help.bind(KeyCode::Tab, none, "help-page-next");
help.bind(KeyCode::Left, none, "help-page-prev");
help.bind(KeyCode::Char('h'), none, "help-page-prev");
help.bind(KeyCode::Char('p'), none, "help-page-prev");
help.bind(KeyCode::BackTab, none, "help-page-prev");
set.insert(ModeKey::Help, Arc::new(help));
// ── Panels ───────────────────────────────────────────────────────
set.insert(
ModeKey::FormulaPanel,
Arc::new(
PanelKeymap::new(Panel::Formula)
.bind_args(
KeyCode::Char('a'),
"enter-mode",
vec!["formula-edit".into()],
)
.bind_args(
KeyCode::Char('n'),
"enter-mode",
vec!["formula-edit".into()],
)
.bind_args(
KeyCode::Char('o'),
"enter-mode",
vec!["formula-edit".into()],
)
.delete("delete-formula-at-cursor")
.finish(),
),
);
set.insert(
ModeKey::CategoryPanel,
Arc::new(
PanelKeymap::new(Panel::Category)
.bind(KeyCode::Enter, "filter-to-item")
.bind(KeyCode::Char(' '), "cycle-axis-at-cursor")
.bind_args(
KeyCode::Char('n'),
"enter-mode",
vec!["category-add".into()],
)
.bind(KeyCode::Char('a'), "open-item-add-at-cursor")
.bind(KeyCode::Char('o'), "open-item-add-at-cursor")
.delete("delete-category-at-cursor")
.finish(),
),
);
set.insert(
ModeKey::ViewPanel,
Arc::new(
PanelKeymap::new(Panel::View)
.bind(KeyCode::Enter, "switch-view-at-cursor")
.bind(KeyCode::Char('n'), "create-and-switch-view")
.bind(KeyCode::Char('o'), "create-and-switch-view")
.delete("delete-view-at-cursor")
.finish(),
),
);
// ── Tile select ──────────────────────────────────────────────────
let mut ts = Keymap::new();
ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_args(
KeyCode::Char('h'),
none,
"move-tile-cursor",
vec!["-1".into()],
);
ts.bind_args(KeyCode::Right, none, "move-tile-cursor", vec!["1".into()]);
ts.bind_args(
KeyCode::Char('l'),
none,
"move-tile-cursor",
vec!["1".into()],
);
ts.bind(KeyCode::Enter, none, "cycle-axis-for-tile");
ts.bind(KeyCode::Char(' '), none, "cycle-axis-for-tile");
ts.bind_args(
KeyCode::Char('r'),
none,
"set-axis-for-tile",
vec!["row".into()],
);
ts.bind_args(
KeyCode::Char('c'),
none,
"set-axis-for-tile",
vec!["column".into()],
);
ts.bind_args(
KeyCode::Char('p'),
none,
"set-axis-for-tile",
vec!["page".into()],
);
ts.bind_args(
KeyCode::Char('n'),
none,
"set-axis-for-tile",
vec!["none".into()],
);
ts.bind_args(
KeyCode::Char(':'),
none,
"enter-mode",
vec!["command".into()],
);
set.insert(ModeKey::TileSelect, Arc::new(ts));
// ── Minibuffer modes ─────────────────────────────────────────────
// All of these share the TextEntry spine (append / pop / Esc-clears);
// only the buffer, the exit mode and the commit keys differ. The
// commit-* commands take their target edit-mode as an argument so the
// command itself stays mode-agnostic.
let ed = Arc::new(
TextEntry::new(&AppMode::editing(), "normal")
.commit(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()])
.commit(
KeyCode::Tab,
"commit-and-advance-right",
vec!["editing".into()],
)
.finish(),
);
set.insert(ModeKey::Editing, ed.clone());
// Records editing inherits from editing, overriding Esc/Enter/Tab so
// the post-commit re-enter targets records-editing.
set.insert(
ModeKey::RecordsEditing,
Arc::new(
TextEntry::with_parent(&AppMode::records_editing(), "records-normal", ed)
.commit(
KeyCode::Enter,
"commit-cell-edit",
vec!["records-editing".into()],
)
.commit(
KeyCode::Tab,
"commit-and-advance-right",
vec!["records-editing".into()],
)
.finish(),
),
);
set.insert(
ModeKey::FormulaEdit,
Arc::new(
TextEntry::new(&AppMode::formula_edit(), "formula-panel")
.commit(KeyCode::Enter, "commit-buffer", vec!["formula".into()])
.finish(),
),
);
// Category and item add stay open after a commit, so Tab and Enter do
// the same thing: file this one and start the next.
set.insert(
ModeKey::CategoryAdd,
Arc::new(
TextEntry::new(&AppMode::category_add(), "category-panel")
.commit(KeyCode::Enter, "commit-buffer", vec!["category".into()])
.commit(KeyCode::Tab, "commit-buffer", vec!["category".into()])
.finish(),
),
);
set.insert(
ModeKey::ItemAdd,
Arc::new(
// The category only shapes the prompt text, never the buffer,
// so the keymap does not need the real one.
TextEntry::new(&AppMode::item_add(String::new()), "category-panel")
.commit(KeyCode::Enter, "commit-buffer", vec!["item".into()])
.commit(KeyCode::Tab, "commit-buffer", vec!["item".into()])
.finish(),
),
);
set.insert(
ModeKey::ExportPrompt,
Arc::new(
TextEntry::new(&AppMode::export_prompt(), "normal")
.commit(KeyCode::Enter, "commit-export", vec![])
.finish(),
),
);
set.insert(
ModeKey::CommandMode,
Arc::new(
TextEntry::new(&AppMode::command_mode(), "normal")
.commit(KeyCode::Enter, "execute-command", vec![])
// Backspace on an empty `:` line leaves command mode
// rather than popping a character that isn't there.
.rebind(KeyCode::Backspace, "command-mode-backspace")
.finish(),
),
);
// ── Search mode ──────────────────────────────────────────────────
let mut sm = Keymap::new();
sm.bind(KeyCode::Esc, none, "exit-search-mode");
sm.bind(KeyCode::Enter, none, "exit-search-mode");
sm.bind_args(KeyCode::Backspace, none, "pop-char", vec!["search".into()]);
sm.bind_any_char("append-char", vec!["search".into()]);
set.insert(ModeKey::SearchMode, Arc::new(sm));
// ── Import wizard ────────────────────────────────────────────────
let mut wiz = Keymap::new();
wiz.bind_any("handle-wizard-key");
set.insert(ModeKey::ImportWizard, Arc::new(wiz));
set
}
}
+34
View File
@@ -31,6 +31,7 @@ use std::collections::HashMap;
pub mod basis; pub mod basis;
pub mod frame; pub mod frame;
pub mod key; pub mod key;
pub mod keymap;
mod reduce; mod reduce;
pub mod render; pub mod render;
pub mod viewmodel; pub mod viewmodel;
@@ -38,6 +39,7 @@ pub mod viewmodel;
pub use basis::{Basis, BasisCache, Rejection}; pub use basis::{Basis, BasisCache, Rejection};
pub use frame::{ClientFrame, ServerFrame}; pub use frame::{ClientFrame, ServerFrame};
pub use key::{KeyCode, KeyEvent, KeyModifiers}; pub use key::{KeyCode, KeyEvent, KeyModifiers};
pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
pub use reduce::{Command, HelpPage, ModelCommand, reduce_view}; pub use reduce::{Command, HelpPage, ModelCommand, reduce_view};
pub use render::{ pub use render::{
CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, FormulaCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, FormulaCache,
@@ -188,6 +190,38 @@ pub enum AppMode {
} }
impl AppMode { impl AppMode {
/// The mode a keymap's `enter-mode <name>` argument names.
///
/// Lives beside `AppMode` rather than in the command layer's registry
/// because the *names* are part of the keymap vocabulary, and the keymap
/// is shared (see [`crate::keymap`]): a browser client resolving
/// `enter-mode` has to decode the same spellings the registry does, and
/// two tables would be the mirror that move existed to delete.
///
/// Not every variant is nameable. `Quit`, `Help`, `ImportWizard`,
/// `ItemAdd` and the three panel modes that *are* nameable are exactly
/// what the registry accepted before the move, and this is that list
/// unchanged — `ItemAdd` is absent because its category is resolved from
/// the runtime mode, never from a script line (gotcha 20).
pub fn from_name(s: &str) -> Option<Self> {
match s {
"normal" => Some(Self::Normal),
"help" => Some(Self::Help),
"formula-panel" => Some(Self::FormulaPanel),
"category-panel" => Some(Self::CategoryPanel),
"view-panel" => Some(Self::ViewPanel),
"tile-select" => Some(Self::TileSelect),
"command" => Some(Self::command_mode()),
"category-add" => Some(Self::category_add()),
"editing" => Some(Self::editing()),
"records-normal" => Some(Self::RecordsNormal),
"records-editing" => Some(Self::records_editing()),
"formula-edit" => Some(Self::formula_edit()),
"export-prompt" => Some(Self::export_prompt()),
_ => None,
}
}
/// Short uppercase name shown in the title bar. /// Short uppercase name shown in the title bar.
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
+25 -1
View File
@@ -113,6 +113,25 @@ pub struct CellDisplay {
/// category column renames a coordinate across the whole row, gotcha 1, /// category column renames a coordinate across the whole row, gotcha 1,
/// which is a different intent from `SetCell`). /// which is a different intent from `SetCell`).
pub id: Option<CellId>, pub id: Option<CellId>,
/// What an editor opened on this cell should start with — the value as
/// the user last typed it, **not** as it is drawn.
///
/// The two differ and the difference corrupts data. `text` has been
/// through `format_f64`, so a stored `1200` is drawn `1,200` under a
/// comma-grouping format; seeding an editor with that and committing it
/// parses as `CellValue::Text("1,200")`, and the number is gone. Display
/// rounding is view-only and must never be fed back into the model
/// (design-principles §4) — an editor's seed is exactly a feedback path.
///
/// It is the projection of `App::display_value_at_cursor`, which is what
/// `EditOrDrill` seeds the TUI's buffer from, so both front ends start
/// from one string rather than each deriving one. `None` where there is
/// nothing to edit, which is the same set of positions `id` is `None`
/// for plus the cells whose value is absent.
///
/// A browser found this: `tests/lockstep.rs` drove `i` through the host
/// and the client over the same grid and they disagreed by a comma.
pub edit_text: Option<String>,
} }
impl CellDisplay { impl CellDisplay {
@@ -124,20 +143,25 @@ impl CellDisplay {
} }
pub fn number(s: impl Into<String>) -> Self { pub fn number(s: impl Into<String>) -> Self {
let text = s.into();
Self { Self {
text: s.into(), edit_text: Some(text.clone()),
text,
numeric: true, numeric: true,
error: false, error: false,
id: None, id: None,
} }
} }
/// An error cell is not editable back into the model — its text is a
/// diagnostic, not a value — so it carries no `edit_text`.
pub fn error(s: impl Into<String>) -> Self { pub fn error(s: impl Into<String>) -> Self {
Self { Self {
text: s.into(), text: s.into(),
numeric: false, numeric: false,
error: true, error: true,
id: None, id: None,
edit_text: None,
} }
} }
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,26 @@ pub struct RenderModel {
pub categories: Vec<RenderCatRow>, pub categories: Vec<RenderCatRow>,
/// Whether the panel is open, so the page does not decide. /// Whether the panel is open, so the page does not decide.
pub category_panel_open: bool, pub category_panel_open: bool,
/// The keys this mode offers, off `AppMode::hint_text()` — the same
/// string the TUI's footer draws.
///
/// The page shows it rather than spelling any keys of its own, which is
/// what keeps a second front end from growing its own idea of the
/// bindings (design-principles §5). It also makes the modes this client
/// can *enter* but not yet *draw* — `Help` above all — state what they
/// are instead of looking like a broken grid.
pub hint: String,
/// What the user has typed so far, while a keyboard edit is open on the
/// selected cell — `None` when none is.
///
/// The page draws this in place of the selected cell's own text. The
/// *decision* — is an edit open, and what is in it — is made here,
/// because it is `AppMode::is_editing()` and the `edit` buffer, and a
/// second front end must not have to know either
/// (design-principles §5). Without it a browser that resolved `i`
/// locally would change its mode indicator and then swallow every
/// keystroke invisibly.
pub editing: Option<String>,
} }
/// A grid row: its header label and its cells. /// A grid row: its header label and its cells.
@@ -76,6 +96,10 @@ pub struct RenderCell {
/// alone. /// alone.
pub editable: bool, pub editable: bool,
pub selected: bool, pub selected: bool,
/// What an editor opened on this cell should start with — the stored
/// value, not the formatted one. See `CellDisplay::edit_text`: seeding
/// from `text` and committing turns `1200` into the string `"1,200"`.
pub edit_text: String,
} }
impl RenderCell { impl RenderCell {
@@ -86,6 +110,7 @@ impl RenderCell {
error: cell.error, error: cell.error,
editable: matches!(cell.id, Some(CellId::Record(_))), editable: matches!(cell.id, Some(CellId::Record(_))),
selected, selected,
edit_text: cell.edit_text.clone().unwrap_or_default(),
} }
} }
} }
@@ -152,6 +177,12 @@ impl ClientState {
.map(|(i, entry)| RenderCatRow::of(entry, i == self.view.cat_panel_cursor)) .map(|(i, entry)| RenderCatRow::of(entry, i == self.view.cat_panel_cursor))
.collect(), .collect(),
category_panel_open: self.view.category_panel_open, category_panel_open: self.view.category_panel_open,
hint: self.view.mode.hint_text().to_string(),
editing: self
.view
.mode
.is_editing()
.then(|| self.view.buffers.get("edit").cloned().unwrap_or_default()),
} }
} }
} }
@@ -371,9 +402,51 @@ mod tests {
let model = client.model(); let model = client.model();
assert_eq!(model.mode, improvise_protocol::AppMode::Normal.label()); assert_eq!(model.mode, improvise_protocol::AppMode::Normal.label());
assert_eq!(model.status, "saved"); assert_eq!(model.status, "saved");
assert!(
model.hint.contains("i:edit"),
"the page draws the mode's own hint rather than spelling keys: {}",
model.hint
);
assert_eq!(model.revision, client.revision()); assert_eq!(model.revision, client.revision());
} }
/// A keyboard edit has to be *visible*, and what is visible is a
/// decision made here rather than in the page.
///
/// The failure this rules out is quiet: a client that resolved `i`
/// locally would change its mode label, put the typing into
/// `buffers["edit"]`, and draw the cell's old value the whole time — so
/// the user sees a keystroke do nothing, types over it, and commits
/// something they never read. `None` outside an editing mode is the
/// other half: a stale buffer must not draw an editor that is not open.
#[test]
fn a_keyboard_edit_in_progress_is_part_of_the_render_model() {
let mut client = client_with_grid(ViewState::default());
assert_eq!(
client.model().editing,
None,
"nothing is being edited in Normal"
);
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
client.state_mut().fold(&Command::SetBuffer {
name: "edit".into(),
value: "12".into(),
});
assert_eq!(client.model().editing.as_deref(), Some("12"));
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::Normal));
assert_eq!(
client.model().editing,
None,
"leaving the mode closes the editor even with the buffer still set"
);
}
/// The whole model crosses as JSON, so the page needs no bindings. /// The whole model crosses as JSON, so the page needs no bindings.
#[test] #[test]
fn the_render_model_crosses_as_json() { fn the_render_model_crosses_as_json() {
@@ -384,5 +457,9 @@ mod tests {
assert_eq!(value["rows"][0]["cells"][0]["editable"], true); assert_eq!(value["rows"][0]["cells"][0]["editable"], true);
assert_eq!(value["rows"][0]["cells"][1]["editable"], false); assert_eq!(value["rows"][0]["cells"][1]["editable"], false);
assert_eq!(value["col_labels"][0], "Revenue"); assert_eq!(value["col_labels"][0], "Revenue");
assert_eq!(
value["rows"][0]["cells"][0]["edit_text"], "1200",
"an editor seeds from the stored value, never from the drawn one"
);
} }
} }
+25 -4
View File
@@ -77,6 +77,30 @@ fn typed(text: &str) -> Option<CellValue> {
} }
} }
/// The `ModelCommand` for "the user typed `text` into this cell".
///
/// The one composition, shared by the mouse path ([`Client::typed_cell_frame`])
/// and the keyboard path (`keys::commit`). Writing it twice is how a browser
/// commit by Enter and a browser commit by click would come to disagree about
/// what an empty string means.
///
/// `None` for an aggregate: `ModelCommand::SetCell` does not take that kind
/// of id, so this is the pattern failing to bind rather than a check (ADR
/// 002).
pub(crate) fn typed_cell_command(
id: &improvise_protocol::CellId,
text: &str,
) -> Option<ModelCommand> {
let improvise_protocol::CellId::Record(cell) = id else {
return None;
};
let cell = cell.clone();
Some(match typed(text) {
Some(value) => ModelCommand::SetCell { cell, value },
None => ModelCommand::ClearCell(cell),
})
}
impl Client { impl Client {
/// The handshake frame, and every resize after it. /// The handshake frame, and every resize after it.
/// ///
@@ -124,10 +148,7 @@ impl Client {
let basis = self.state().basis.claim([cell.record])?; let basis = self.state().basis.claim([cell.record])?;
Some(ClientFrame::command(Command::Model { Some(ClientFrame::command(Command::Model {
basis, basis,
cmd: match typed(text) { cmd: typed_cell_command(&improvise_protocol::CellId::Record(cell), text)?,
Some(value) => ModelCommand::SetCell { cell, value },
None => ModelCommand::ClearCell(cell),
},
})) }))
} }
} }
@@ -428,3 +428,179 @@ fn a_locally_resolved_tile_cursor_agrees_with_the_host() {
} }
assert!(client.view.tile_cat_idx > 0, "the fixture moved nothing"); assert!(client.view.tile_cat_idx > 0, "the fixture moved nothing");
} }
// ── the editing path (improvise-bpq2's second half) ─────────────────────
/// A document whose cells are **records**, so `i` has something to edit.
///
/// One regular category on Row, `_Measure` on Column, nothing regular on
/// None — the shape gotcha 46 built `web/demo.improv` to satisfy, and the
/// reason is the same: with a regular category on None every cell is an
/// `Aggregate` and the whole editing path is unreachable.
fn editable_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.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.model.set_cell(
improvise_command::model::cell::CellKey::new(vec![
("Region".to_string(), "North".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
improvise_protocol::CellValue::Number(1200.0),
);
let mut app = App::new(wb, None);
app.term_height = 2 + 8;
app.rebuild_layout();
app
}
/// [`client_of`], plus the published basis a real client folds before it is
/// allowed to compose a write (ADR 001). The tiers are the ones this test
/// never checks — the host applies the keypress directly rather than the
/// client's `Command::Model` — but `BasisCache::claim` withholds until it
/// has *something*, so a client without one resolves no commit at all.
fn client_ready_to_edit(app: &App, window: (usize, usize)) -> ClientState {
let mut client = client_of(app, window);
let records: std::collections::BTreeMap<_, _> = client
.grid
.cells
.iter()
.flatten()
.filter_map(|cell| match cell.id.as_ref() {
Some(improvise_protocol::CellId::Record(r)) => Some((r.record, 0u64)),
_ => None,
})
.collect();
assert!(
!records.is_empty(),
"the fixture must project at least one Record cell, or nothing here is testing editing"
);
improvise_protocol::WidgetCache::apply(
&mut client.basis,
&improvise_protocol::basis::published(0, 0, records),
);
client
}
/// The headline claim for the editing half: `i`, three characters and `Esc`
/// leave the client and the host in the same mode with the same buffers.
///
/// `Esc` rather than `Enter` for the cancel arm, because it is a
/// `Binding::Sequence` and so exercises the all-or-nothing rule: half of it —
/// a cleared buffer in a mode the user has not left — is worse than none.
#[test]
fn entering_typing_and_cancelling_an_edit_agrees_with_the_host() {
let window = (2, 1);
let mut app = editable_host();
let mut client = client_ready_to_edit(&app, window);
for key in ["i", "9", "9", "9", "Escape"] {
let event = BrowserKey::new(key).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{key}: the client must answer this key");
};
for cmd in &cmds {
client.fold(cmd);
}
reduce_key(&mut app, event);
assert_eq!(
format!("{:?}", client.view.mode),
format!("{:?}", app.view_state.mode),
"{key}: client and host disagree about the mode"
);
assert_eq!(
client.view.buffers, app.view_state.buffers,
"{key}: client and host disagree about the buffers"
);
}
assert!(
matches!(app.view_state.mode, AppMode::Normal),
"Esc must have left editing, or the walk above proved nothing"
);
}
/// `i` seeds the edit buffer from the cell's **display value**, and the two
/// ends must agree on what that is: the host reads
/// `App::display_value_at_cursor`, the client reads the text the server
/// projected into its `GridCache`. Those are two paths to one string, and
/// this is the assertion that they are the same string.
#[test]
fn the_buffer_i_seeds_is_the_value_the_host_would_seed() {
let window = (2, 1);
let mut app = editable_host();
let mut client = client_ready_to_edit(&app, window);
let event = BrowserKey::new("i").to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("i on a record cell must resolve locally");
};
for cmd in &cmds {
client.fold(cmd);
}
reduce_key(&mut app, event);
assert_eq!(
client.view.buffers.get("edit"),
app.view_state.buffers.get("edit"),
"the seeded buffer must be the host's display value"
);
assert_eq!(
client.view.buffers.get("edit").map(String::as_str),
Some("1200"),
"and the fixture must actually have a value, or this passes on two empties"
);
}
/// The commit: `Enter` in `Editing` moves both ends to the same mode, the
/// same cursor and the same buffers.
///
/// What is *not* compared is the document, and deliberately: the client
/// answers with a `Command::Model`, which `reduce_view` drops, while
/// `reduce_key` writes through the effect pipeline. The write's composition
/// is asserted in `keys.rs` against `typed_cell`'s own — the shared one — so
/// what is left to prove here is the half a unit test cannot see, which is
/// that the advance and the re-entry land in the same place.
#[test]
fn a_locally_committed_edit_leaves_both_ends_in_the_same_place() {
let window = (2, 1);
let mut app = editable_host();
let mut client = client_ready_to_edit(&app, window);
for key in ["i", "7", "Enter"] {
let event = BrowserKey::new(key).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{key}: the client must answer this key");
};
for cmd in &cmds {
client.fold(cmd);
}
reduce_key(&mut app, event);
assert_eq!(
format!("{:?}", client.view.mode),
format!("{:?}", app.view_state.mode),
"{key}: client and host disagree about the mode"
);
assert_eq!(
client.view.cursor(),
app.view_state.cursor(),
"{key}: client and host disagree about the cursor"
);
assert_eq!(
client.view.buffers, app.view_state.buffers,
"{key}: client and host disagree about the buffers"
);
}
assert_eq!(
app.view_state.cursor().selected,
(1, 0),
"the commit must have advanced a row, or the walk proved nothing"
);
}
+20 -5
View File
@@ -84,6 +84,7 @@ const el = {
grid: document.getElementById("grid"), grid: document.getElementById("grid"),
categories: document.getElementById("categories"), categories: document.getElementById("categories"),
mode: document.getElementById("mode"), mode: document.getElementById("mode"),
hint: document.getElementById("hint"),
status: document.getElementById("status"), status: document.getElementById("status"),
viewName: document.getElementById("view-name"), viewName: document.getElementById("view-name"),
}; };
@@ -233,6 +234,9 @@ function repaint() {
painted = model.revision; painted = model.revision;
el.mode.textContent = model.mode; el.mode.textContent = model.mode;
// The mode's own hint, off `AppMode::hint_text()`. The page spells no
// keys itself; see `RenderModel::hint`.
el.hint.textContent = model.hint;
modelStatus = model.status; modelStatus = model.status;
paintStatus(); paintStatus();
el.viewName.textContent = model.view_name; el.viewName.textContent = model.view_name;
@@ -261,15 +265,23 @@ function gridTable(model) {
row.cells.forEach((cell, c) => { row.cells.forEach((cell, c) => {
const td = tr.insertCell(); const td = tr.insertCell();
td.textContent = cell.text; // A keyboard edit is open on the selected cell when `model.editing` is
td.classList.toggle("numeric", cell.numeric); // a string; it holds what has been typed so far. Both facts are
// decided in Rust (`RenderModel::editing`) — the page does not know
// which modes are editing modes or which buffer they type into.
// Without this the mode indicator would say INSERT while every
// keystroke vanished into a buffer nobody drew.
const typing = cell.selected && typeof model.editing === "string";
td.textContent = typing ? model.editing + "\u258f" : cell.text;
td.classList.toggle("numeric", cell.numeric && !typing);
td.classList.toggle("error", cell.error); td.classList.toggle("error", cell.error);
td.classList.toggle("selected", cell.selected); td.classList.toggle("selected", cell.selected);
td.classList.toggle("typing", typing);
// `editable` is the server's answer relayed through Rust: a stored // `editable` is the server's answer relayed through Rust: a stored
// cell. An aggregate is a drill target and drill is not on the wire // cell. An aggregate is a drill target and drill is not on the wire
// yet, so it is simply not offered here. // yet, so it is simply not offered here.
td.classList.toggle("editable", cell.editable); td.classList.toggle("editable", cell.editable);
if (cell.editable) td.addEventListener("click", () => edit(td, r, c)); if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
}); });
}); });
return table; return table;
@@ -277,10 +289,13 @@ function gridTable(model) {
/** Open an inline editor. Committing sends a frame; the value that comes /** Open an inline editor. Committing sends a frame; the value that comes
* back is the server's, never the string typed here. */ * back is the server's, never the string typed here. */
function edit(td, row, col) { function edit(td, row, col, seed) {
if (editing) return; if (editing) return;
editing = { row, col }; editing = { row, col };
const original = td.textContent; // `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn
// one. `td.textContent` has been through the number format, so a stored
// 1200 reads "1,200" and committing that stores the string "1,200".
const original = seed;
const input = document.createElement("input"); const input = document.createElement("input");
input.value = original; input.value = original;
td.replaceChildren(input); td.replaceChildren(input);
+1
View File
@@ -36,6 +36,7 @@
<h1>improvise</h1> <h1>improvise</h1>
<span id="view-name"></span> <span id="view-name"></span>
<span id="mode"></span> <span id="mode"></span>
<span id="hint"></span>
<span id="status" role="status"></span> <span id="status" role="status"></span>
</header> </header>
+12
View File
@@ -89,6 +89,18 @@ td.selected {
outline: 1px solid var(--accent); outline: 1px solid var(--accent);
} }
#hint {
opacity: 0.7;
font-size: 0.85em;
}
/* A keyboard edit in progress. Left-aligned even in a numeric column,
because what is drawn is the text being typed rather than a value. */
td.typing {
text-align: left;
outline: 2px solid var(--accent);
}
#grid { #grid {
flex: 1; flex: 1;
overflow: auto; overflow: auto;