review(p9-fb-12): 회차 1 nit 반영
- `mode_intercept` 를 `pub` 로 노출 + `pub use run::mode_intercept`
로 lib.rs export. 신규 `tests/mode.rs` 6 integration unit:
- Esc-from-Insert flips to Normal on every pane (consumed)
- Esc-from-Normal falls through (pane handler 가 처리 — Library
의 quit signal 등 보존)
- i-from-Normal on Library/Inspect/Jobs flips to Insert (consumed)
- i-on-Search/Ask falls through (이미 Insert, i 가 typed char)
- Ctrl/Alt modifier 는 intercept 안 함 (chord 가능)
- Shift+Esc 는 toggle 됨 (modifier filter 가 SHIFT allow), Shift+I
(capital) 는 fall-through (lowercase i 만 toggle 키)
- `Mode::auto_for` doc 에 \"auto-flip overrides user manual mode on
pane switch\" 명시 — 의도된 트레이드오프 (typing 이 Search/Ask 의
dominant case). sticky-per-pane 은 future task.
워크스페이스 clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,15 @@ impl Mode {
|
||||
/// Library / Inspect are read-only navigation panes (`Normal`);
|
||||
/// Search / Ask are typing panes so we pre-flip to `Insert` so
|
||||
/// the user doesn't have to press `i` after every Tab.
|
||||
///
|
||||
/// **Auto-flip overrides any prior user-flipped mode on pane
|
||||
/// switch** — if a user pressed `Esc` on Search to read scroll-
|
||||
/// back, then Tab'd back into Ask, the next focus auto-flips
|
||||
/// to Insert (clobbering the user's Normal). This is
|
||||
/// intentional: the typing case is the dominant one for
|
||||
/// Search/Ask, and a sticky-per-pane mode adds state most
|
||||
/// users don't ask for. Sticky mode is a future task —
|
||||
/// current heuristic optimizes for the common case.
|
||||
pub fn auto_for(pane: Pane) -> Self {
|
||||
match pane {
|
||||
Pane::Search | Pane::Ask => Mode::Insert,
|
||||
|
||||
@@ -50,3 +50,7 @@ pub use search::{build_jump_command, handle_key_search, render_search};
|
||||
// the test) and can pin the in-flight-skip invariant of debounce.
|
||||
pub use search::poll_worker as poll_search_worker;
|
||||
pub use search::debounce_due as search_debounce_due;
|
||||
// p9-fb-12: expose the global mode-toggle intercept so integration
|
||||
// tests can pin the i/Esc behavior without standing up the full
|
||||
// run loop.
|
||||
pub use run::mode_intercept;
|
||||
|
||||
@@ -350,7 +350,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
/// swallow what should be a typed character. We let `i` fall
|
||||
/// through there.)
|
||||
/// - Everything else → not consumed.
|
||||
fn mode_intercept(app: &mut crate::app::App, key: crossterm::event::KeyEvent) -> bool {
|
||||
///
|
||||
/// `pub` so integration tests + future TUI consumers can drive the
|
||||
/// intercept paths by constructing KeyEvents directly without
|
||||
/// standing up the full run loop.
|
||||
pub fn mode_intercept(app: &mut crate::app::App, key: crossterm::event::KeyEvent) -> bool {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use crate::app::{Mode, Pane};
|
||||
|
||||
|
||||
133
crates/kebab-tui/tests/mode.rs
Normal file
133
crates/kebab-tui/tests/mode.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! p9-fb-12: integration tests for `mode_intercept`. Drives the
|
||||
//! global i/Esc dispatch by constructing KeyEvents directly without
|
||||
//! standing up the full run loop (terminal-side).
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use kebab_config::Config;
|
||||
use kebab_tui::{App, Mode, Pane, mode_intercept};
|
||||
|
||||
fn fresh_app(focus: Pane) -> App {
|
||||
let mut config = Config::defaults();
|
||||
config.storage.data_dir = "/tmp/kebab-tui-mode-tests-noop".to_string();
|
||||
config.workspace.root = "/tmp/kebab-tui-mode-tests-noop/workspace".to_string();
|
||||
let mut app = App::new(config).expect("App::new");
|
||||
app.focus = focus;
|
||||
app.mode = Mode::auto_for(focus);
|
||||
app
|
||||
}
|
||||
|
||||
/// p9-fb-12: `Esc` from Insert mode flips to Normal on any pane.
|
||||
/// Returns `true` (consumed) so the pane handler doesn't ALSO see
|
||||
/// the Esc as a "back to Library" signal.
|
||||
#[test]
|
||||
fn esc_in_insert_flips_to_normal_and_consumes() {
|
||||
for &pane in &[Pane::Library, Pane::Search, Pane::Ask, Pane::Inspect] {
|
||||
let mut app = fresh_app(pane);
|
||||
app.mode = Mode::Insert;
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(consumed, "Esc in Insert must be consumed (pane: {pane:?})");
|
||||
assert_eq!(app.mode, Mode::Normal, "mode flipped to Normal (pane: {pane:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-12: `Esc` from Normal mode is a no-op (not consumed) so the
|
||||
/// pane's existing Esc handler (e.g. Library `Esc` → quit) keeps
|
||||
/// working.
|
||||
#[test]
|
||||
fn esc_in_normal_mode_falls_through() {
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
assert_eq!(app.mode, Mode::Normal);
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!consumed, "Esc in Normal must fall through to pane");
|
||||
assert_eq!(app.mode, Mode::Normal, "mode unchanged");
|
||||
}
|
||||
|
||||
/// p9-fb-12: `i` in Normal mode on Library / Inspect / Jobs flips
|
||||
/// to Insert. (`i` has no pre-fb-12 meaning on those panes, so the
|
||||
/// global interception is safe.)
|
||||
#[test]
|
||||
fn i_in_normal_on_library_inspect_jobs_flips_to_insert() {
|
||||
for &pane in &[Pane::Library, Pane::Inspect, Pane::Jobs] {
|
||||
let mut app = fresh_app(pane);
|
||||
assert_eq!(app.mode, Mode::Normal, "auto_for({pane:?}) should be Normal");
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(consumed, "i in Normal on {pane:?} must be consumed");
|
||||
assert_eq!(app.mode, Mode::Insert, "mode flipped to Insert (pane: {pane:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-12: `i` on Search / Ask falls through (the pane is already
|
||||
/// in Insert via Mode::auto_for, so the global `i` interception
|
||||
/// would swallow what should be a typed character).
|
||||
#[test]
|
||||
fn i_on_search_or_ask_falls_through_to_pane() {
|
||||
for &pane in &[Pane::Search, Pane::Ask] {
|
||||
let mut app = fresh_app(pane);
|
||||
assert_eq!(app.mode, Mode::Insert, "auto_for({pane:?}) should be Insert");
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!consumed, "i on {pane:?} must fall through to pane");
|
||||
assert_eq!(app.mode, Mode::Insert, "mode unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-12: modifier-bearing keys (Ctrl+Esc, Alt+i) are NOT the
|
||||
/// mode toggle. Falls through so chord handlers downstream get a
|
||||
/// shot.
|
||||
#[test]
|
||||
fn modifier_keys_do_not_trigger_intercept() {
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
app.mode = Mode::Insert;
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(!consumed, "Ctrl+Esc must fall through");
|
||||
assert_eq!(app.mode, Mode::Insert, "mode unchanged");
|
||||
|
||||
app.mode = Mode::Normal;
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(!consumed, "Alt+i must fall through");
|
||||
assert_eq!(app.mode, Mode::Normal, "mode unchanged");
|
||||
}
|
||||
|
||||
/// p9-fb-12: SHIFT alone is allowed (the toggle keys are unshifted
|
||||
/// `i` / `Esc`, but a future `Shift+Esc` chord is unlikely; pre-
|
||||
/// allow SHIFT so capital-letter typing in Search/Ask doesn't
|
||||
/// accidentally fall into the modifier-block branch).
|
||||
#[test]
|
||||
fn shift_modifier_passes_modifier_filter() {
|
||||
// SHIFT+Esc is a strange combo but the filter passes it. (The
|
||||
// actual outcome — does mode flip? — depends on the case
|
||||
// matching i/Esc. SHIFT+Esc still matches KeyCode::Esc, so it
|
||||
// toggles. SHIFT+I would be KeyCode::Char('I') (capital), NOT
|
||||
// 'i', so it falls through. Both are intentional.)
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
app.mode = Mode::Insert;
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT),
|
||||
);
|
||||
assert!(consumed, "Shift+Esc still toggles (modifier filter allows SHIFT)");
|
||||
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
let consumed = mode_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT),
|
||||
);
|
||||
assert!(!consumed, "Shift+I (capital) falls through — only lowercase 'i' toggles");
|
||||
}
|
||||
Reference in New Issue
Block a user