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:
@@ -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(®istry, 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user