fix(mode): a minibuffer opened over a records grid returns to it (improvise-sihr)

`AppMode::Normal` is now resolved through `App::normal_mode()` at the moment the
mode is set, so no keymap binding and no commit command carries an exit mode.
Two readers, one function.

The ticket proposed carrying the entry mode on `MinibufferConfig`; declined, with
the reason in context/test-evidence/command.md — carried generically it also
returns `CategoryAdd` to `CategoryPanel`, and carried for three modes it is the
one-arm shape the ticket itself argues against.

The binding table is untouched, so `doctrine(binding_dump_diff)` has nothing to
diff: what was lost was the runtime mode, not a binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 03:51:37 -07:00
parent 3f53181308
commit 6782e8555c
3 changed files with 139 additions and 28 deletions
+69 -17
View File
@@ -554,6 +554,15 @@ impl App {
self.cursor_epoch
}
/// What `AppMode::Normal` means on the grid this batch opened over: a records
/// layout has its own. Observed, never carried — `context/code/command.md`.
pub fn normal_mode(&self) -> AppMode {
match self.layout.is_records_mode() {
true => AppMode::RecordsNormal,
false => AppMode::Normal,
}
}
/// Which views the last batch actually reshaped. **Empty means "not known"**,
/// never "none" — a reader must fail safe (improvise-nynk).
pub fn last_reshaped_views(&self) -> &std::collections::BTreeSet<String> {
@@ -2394,15 +2403,22 @@ mod tests {
);
}
/// Every minibuffer mode reachable from `RecordsNormal` exits to plain
/// `Normal`, and the class **predates** `SavePrompt`. See the evidence.
/// Every minibuffer mode reachable from `RecordsNormal` returns there, by
/// cancel and by commit alike. See the evidence.
#[test]
fn every_minibuffer_mode_entered_from_records_returns_to_plain_normal() {
for key in [
fn every_minibuffer_mode_entered_from_records_returns_to_records_normal() {
let opens = [
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
] {
];
// The exit is spelled in two places per mode — the `TextEntry` spine's
// Esc and the commit command — so both are driven.
let exits = [
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
];
for (key, exit) in opens.into_iter().flat_map(|k| exits.map(|e| (k, e))) {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
@@ -2423,24 +2439,60 @@ mod tests {
app.view_state.mode
);
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.view_state.mode,
AppMode::Normal,
"{:?} cancels back to plain Normal, not RecordsNormal — the \
layout is still a records layout, so this is the bug, and \
it is the same for all three keys",
key.code
);
app.handle_key(exit).unwrap();
assert!(
app.layout.is_records_mode(),
"...and the layout really is still records, which is what \
makes the mode wrong rather than merely different"
"setup: the layout is still a records layout, which is what \
makes plain Normal wrong rather than merely different"
);
assert_eq!(
app.view_state.mode,
AppMode::RecordsNormal,
"{:?} left by {:?} into plain Normal over a records layout \
(improvise-sihr)",
key.code,
exit.code
);
}
}
/// A `:` line that really runs settles through `ReturnToNormalUnlessMoved`,
/// which an empty line never reaches. See the evidence.
#[test]
fn a_colon_line_run_from_records_settles_back_into_records_normal() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
const LINE: &str = "page-next";
for c in LINE.chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let registry = crate::command::cmd::registry::default_registry();
assert!(
crate::command::parse::parse_line_with(&registry, LINE).is_ok(),
"setup: an unparseable line settles through ChangeMode instead, \
which is the arm the sibling test already covers"
);
assert!(
app.layout.is_records_mode(),
"setup: the line ran over a records layout"
);
assert_eq!(
app.view_state.mode,
AppMode::RecordsNormal,
"a run `:` line settled into plain Normal (improvise-sihr)"
);
}
/// A failing `:` command must not strand the user in `CommandMode`: tidy-up
/// goes at the head, where an abort cannot reach it (gotcha 21).
#[test]
+17 -3
View File
@@ -504,7 +504,21 @@ simple_effect!(SetColOffset(usize) => |app| app.cursor_mut().col_offset, EffectK
// ── App state ────────────────────────────────────────────────────────────────
simple_effect!(ChangeMode(AppMode) => |app| app.view_state.mode, EffectKind::ViewOnly);
/// Set the mode. `Normal` is resolved through [`App::normal_mode`], so nothing
/// carries an exit mode and no caller can forget one (improvise-sihr).
#[derive(Debug)]
pub struct ChangeMode(pub AppMode);
impl Effect for ChangeMode {
fn apply(&self, app: &mut App) {
app.view_state.mode = match self.0 {
AppMode::Normal => app.normal_mode(),
ref mode => mode.clone(),
};
}
fn kind(&self) -> EffectKind {
EffectKind::ViewOnly
}
}
/// Return to `Normal` unless something earlier in this batch already moved the
/// mode away from `from`. Observed, never predicted (improvise-toc, gotcha 31).
@@ -513,7 +527,7 @@ pub struct ReturnToNormalUnlessMoved(pub AppMode);
impl Effect for ReturnToNormalUnlessMoved {
fn apply(&self, app: &mut App) {
if app.view_state.mode == self.0 {
app.view_state.mode = AppMode::Normal;
app.view_state.mode = app.normal_mode();
}
}
fn kind(&self) -> EffectKind {
@@ -797,7 +811,7 @@ pub struct CloseWizard;
impl Effect for CloseWizard {
fn apply(&self, app: &mut App) {
app.wizard = None;
app.view_state.mode = AppMode::Normal;
app.view_state.mode = app.normal_mode();
}
fn kind(&self) -> EffectKind {
EffectKind::ViewOnly