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:
@@ -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
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user