mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
feat(tui): single-source what a key means, starting with fleet_detail (#6290)
`menu_style.rs` already single-sources the selection vocabulary, and draws its
boundary explicitly: "This module owns *styling* only. Verbs, action-hint
labels, keybindings, and localized strings stay with the views." That boundary
is the defect. Appearance got unified; movement never did, so every surface
reinvented it — `h`/`l` in `provider_picker` against `Left`/`Right` in
`model_picker` one screen later, `Home`/`End` in one of seven pickers, and no
paging at all in `fleet_detail`.
`list_nav` already owned the arithmetic ("without each picker inventing its own
clamp"). This extends it to the vocabulary rather than adding a rival module,
since arithmetic and vocabulary are the same concern one layer apart.
**Two entry points, because the difference is load-bearing.** `motion` is for a
surface with nothing focused for typing and includes the `j`/`k`/`h`/`l`
aliases. `motion_while_typing` is arrow/page/Home/End only. A picker with a live
filter that routed `j` to "move down" would eat the letter out of the user's
query — `fleet_detail`'s own route-pick step feeds `Char(c)` into `pick_query`,
so it takes the typing-safe set while its overview list takes the full one. A
test asserts no letter is ever claimed by the typing-safe set, because that is
the bug this shape exists to prevent.
Horizontal motions are named `RegionPrev`/`RegionNext` — what they do, not which
key — and `apply` returns `None` for them, because only the surface knows
whether it has a second axis. `h`/`l` and `Left`/`Right` now resolve to the same
motion, which is pinned by a test.
**It pays for itself in the first adoption.** Adopting it in `fleet_detail`
made `move_row` dead, and it is deleted. The overview list gains PageUp/PageDown
and Home/End it never had, and the route picker gains them too — not by writing
six arms per surface, but by not writing any. Per AGENTS.md an abstraction must
delete caller code; this one does on its first caller, and the remaining
pickers (#6290 steps 2 and 3) are where the rest comes from.
Not done here, deliberately: the other six pickers and the three sibling Fleet
views. #6290 records why Fleet's shape question — four views, 8,501 lines —
cannot be answered until movement is uniform, or the rearrangement bakes four
key vocabularies in.
Tests: `cargo nextest run -p codewhale-tui --lib -E 'test(fleet) or
test(list_nav) or test(picker)'` — 927 run, 927 passed. Full crate: 12811 run,
12811 passed, 0 failed, 19 skipped. `cargo fmt --all -- --check` clean.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
363409c087
commit
044eed586f
2 files changed
+229
-24
No files matched your search
@@ -1,9 +1,26 @@
|
||||
//! Shared list-selection navigation (#4755).
|
||||
//! Shared list-selection navigation (#4755, #6290).
|
||||
//!
|
||||
//! Modal lists and config screens should wrap at the ends so Down on the last
|
||||
//! row returns to the top and Up on the first row returns to the bottom.
|
||||
//! Centralizing the arithmetic keeps that behavior consistent without each
|
||||
//! picker inventing its own clamp.
|
||||
//!
|
||||
//! [`Motion`] extends that from the arithmetic to the *vocabulary*. `menu_style`
|
||||
//! single-sources how a selected row looks; this single-sources what a key
|
||||
//! means, which is the half that was still being reinvented per view: `h`/`l`
|
||||
//! in the provider picker against `Left`/`Right` in the model picker one screen
|
||||
//! later, `End` in exactly one of seven pickers, and no paging at all in
|
||||
//! `fleet_detail` (#6290 has the tables).
|
||||
//!
|
||||
//! Two entry points, because the difference is load-bearing:
|
||||
//! [`motion`] for a surface with no focused text input, and
|
||||
//! [`motion_while_typing`] for one that is capturing characters. The letter
|
||||
//! aliases exist only in the first. A picker with a live filter that routed
|
||||
//! `j` to "move down" would eat the letter out of the user's query, so the
|
||||
//! typing-safe set is arrow-and-page only. Surfaces choose by what they are
|
||||
//! doing, not by what they are.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
/// Move a 0-based selection by `delta`, wrapping at both ends.
|
||||
///
|
||||
@@ -16,9 +33,184 @@ pub fn wrap_index(selected: usize, len: usize, delta: isize) -> usize {
|
||||
(selected as isize + delta).rem_euclid(len as isize) as usize
|
||||
}
|
||||
|
||||
/// One movement a selection surface can be asked to make.
|
||||
///
|
||||
/// Horizontal motions are named for what they do — move between panes,
|
||||
/// columns or tab strips — rather than for a key, so a surface that has no
|
||||
/// second axis simply never asks for them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Motion {
|
||||
/// One row toward the start.
|
||||
Prev,
|
||||
/// One row toward the end.
|
||||
Next,
|
||||
/// One screenful toward the start.
|
||||
PagePrev,
|
||||
/// One screenful toward the end.
|
||||
PageNext,
|
||||
/// The first row.
|
||||
First,
|
||||
/// The last row.
|
||||
Last,
|
||||
/// The previous pane, column or tab.
|
||||
RegionPrev,
|
||||
/// The next pane, column or tab.
|
||||
RegionNext,
|
||||
}
|
||||
|
||||
/// The motion a key asks for on a surface with **no focused text input**.
|
||||
///
|
||||
/// `j`/`k` and `h`/`l` are aliases here and nowhere else; see the module doc.
|
||||
/// A key carrying CONTROL or ALT is never a motion — those belong to the
|
||||
/// surface's own shortcuts.
|
||||
#[must_use]
|
||||
pub fn motion(key: &KeyEvent) -> Option<Motion> {
|
||||
if key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('k') => Some(Motion::Prev),
|
||||
KeyCode::Char('j') => Some(Motion::Next),
|
||||
KeyCode::Char('h') => Some(Motion::RegionPrev),
|
||||
KeyCode::Char('l') => Some(Motion::RegionNext),
|
||||
_ => motion_while_typing(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// The motion a key asks for while the surface is **capturing typed text**.
|
||||
///
|
||||
/// Arrows, paging and Home/End only, so no letter is ever taken out of a
|
||||
/// query. Tab still moves between regions: it is not a character a filter
|
||||
/// wants.
|
||||
#[must_use]
|
||||
pub fn motion_while_typing(key: &KeyEvent) -> Option<Motion> {
|
||||
if key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up => Some(Motion::Prev),
|
||||
KeyCode::Down => Some(Motion::Next),
|
||||
KeyCode::PageUp => Some(Motion::PagePrev),
|
||||
KeyCode::PageDown => Some(Motion::PageNext),
|
||||
KeyCode::Home => Some(Motion::First),
|
||||
KeyCode::End => Some(Motion::Last),
|
||||
KeyCode::Left => Some(Motion::RegionPrev),
|
||||
KeyCode::Right => Some(Motion::RegionNext),
|
||||
KeyCode::BackTab => Some(Motion::RegionPrev),
|
||||
KeyCode::Tab => Some(Motion::RegionNext),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a vertical [`Motion`] to a 0-based selection.
|
||||
///
|
||||
/// `Prev`/`Next` wrap, matching [`wrap_index`]. Paging and `First`/`Last`
|
||||
/// clamp: a user pressing PageDown is asking to travel, not to teleport to the
|
||||
/// top. Returns `None` for a horizontal motion, which only the surface can
|
||||
/// resolve.
|
||||
#[must_use]
|
||||
pub fn apply(selected: usize, len: usize, page: usize, motion: Motion) -> Option<usize> {
|
||||
if len == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
let last = len - 1;
|
||||
let page = page.max(1) as isize;
|
||||
Some(match motion {
|
||||
Motion::Prev => wrap_index(selected, len, -1),
|
||||
Motion::Next => wrap_index(selected, len, 1),
|
||||
Motion::PagePrev => (selected as isize - page).max(0) as usize,
|
||||
Motion::PageNext => (selected as isize + page).min(last as isize) as usize,
|
||||
Motion::First => 0,
|
||||
Motion::Last => last,
|
||||
Motion::RegionPrev | Motion::RegionNext => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::wrap_index;
|
||||
use super::{Motion, apply, motion, motion_while_typing, wrap_index};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// The reason there are two entry points. A picker with a live filter that
|
||||
/// routed `j` to "move down" would silently eat the letter out of the
|
||||
/// user's query (#6290).
|
||||
#[test]
|
||||
fn typing_safe_motions_never_claim_a_letter() {
|
||||
for c in ['j', 'k', 'h', 'l', 'g', 'q'] {
|
||||
assert_eq!(
|
||||
motion_while_typing(&key(KeyCode::Char(c))),
|
||||
None,
|
||||
"`{c}` must stay available to a text filter"
|
||||
);
|
||||
}
|
||||
assert_eq!(motion_while_typing(&key(KeyCode::Down)), Some(Motion::Next));
|
||||
assert_eq!(motion_while_typing(&key(KeyCode::End)), Some(Motion::Last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_aliases_exist_only_where_nothing_is_being_typed() {
|
||||
assert_eq!(motion(&key(KeyCode::Char('j'))), Some(Motion::Next));
|
||||
assert_eq!(motion(&key(KeyCode::Char('k'))), Some(Motion::Prev));
|
||||
assert_eq!(motion(&key(KeyCode::Char('h'))), Some(Motion::RegionPrev));
|
||||
assert_eq!(motion(&key(KeyCode::Char('l'))), Some(Motion::RegionNext));
|
||||
}
|
||||
|
||||
/// `h`/`l` and `Left`/`Right` were two idioms for one motion in two pickers
|
||||
/// one screen apart. They resolve to the same `Motion` now.
|
||||
#[test]
|
||||
fn the_two_horizontal_idioms_agree() {
|
||||
assert_eq!(
|
||||
motion(&key(KeyCode::Char('l'))),
|
||||
motion(&key(KeyCode::Right))
|
||||
);
|
||||
assert_eq!(
|
||||
motion(&key(KeyCode::Char('h'))),
|
||||
motion(&key(KeyCode::Left))
|
||||
);
|
||||
assert_eq!(motion(&key(KeyCode::Tab)), Some(Motion::RegionNext));
|
||||
}
|
||||
|
||||
/// A surface's own shortcuts keep their keys: Ctrl/Alt is never a motion.
|
||||
#[test]
|
||||
fn modified_keys_are_not_motions() {
|
||||
let ctrl_down = KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL);
|
||||
assert_eq!(motion(&ctrl_down), None);
|
||||
assert_eq!(motion_while_typing(&ctrl_down), None);
|
||||
}
|
||||
|
||||
/// Rows wrap; pages clamp. PageDown is a request to travel, not to
|
||||
/// teleport back to the top.
|
||||
#[test]
|
||||
fn rows_wrap_and_pages_clamp() {
|
||||
assert_eq!(apply(2, 3, 10, Motion::Next), Some(0));
|
||||
assert_eq!(apply(0, 3, 10, Motion::Prev), Some(2));
|
||||
assert_eq!(apply(1, 40, 10, Motion::PageNext), Some(11));
|
||||
assert_eq!(apply(38, 40, 10, Motion::PageNext), Some(39));
|
||||
assert_eq!(apply(3, 40, 10, Motion::PagePrev), Some(0));
|
||||
assert_eq!(apply(7, 40, 10, Motion::Last), Some(39));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_motion_is_the_surfaces_to_resolve() {
|
||||
assert_eq!(apply(1, 5, 10, Motion::RegionNext), None);
|
||||
assert_eq!(apply(1, 5, 10, Motion::RegionPrev), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_has_no_selection_to_move() {
|
||||
assert_eq!(apply(0, 0, 10, Motion::Next), Some(0));
|
||||
assert_eq!(apply(0, 0, 10, Motion::Last), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_forward_and_backward() {
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Rows a PageUp/PageDown travels. Both lists here are modal and short, so a
|
||||
/// page is a readable jump rather than a screenful measured at paint time.
|
||||
const DETAIL_PAGE: usize = 10;
|
||||
const ROUTE_PICK_PAGE: usize = 10;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
@@ -227,10 +232,6 @@ impl FleetDetailView {
|
||||
.and_then(|idx| self.fleet.members.get(idx))
|
||||
}
|
||||
|
||||
fn move_row(&mut self, delta: isize) {
|
||||
self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), delta);
|
||||
}
|
||||
|
||||
fn start_rename(&mut self) {
|
||||
self.rename_mode = true;
|
||||
self.rename_input = self.fleet.name.clone();
|
||||
@@ -726,18 +727,22 @@ impl ModalView for FleetDetailView {
|
||||
self.step = DetailStep::Overview;
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Up => {
|
||||
let len = self.filtered_routes().len();
|
||||
if len > 0 {
|
||||
self.pick_row = crate::tui::list_nav::wrap_index(self.pick_row, len, -1);
|
||||
}
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let len = self.filtered_routes().len();
|
||||
if len > 0 {
|
||||
self.pick_row = crate::tui::list_nav::wrap_index(self.pick_row, len, 1);
|
||||
}
|
||||
// Typing-safe set only: this step feeds `Char(c)` into
|
||||
// `pick_query`, so a letter alias would eat the query (#6290).
|
||||
_ if crate::tui::list_nav::motion_while_typing(&key)
|
||||
.and_then(|m| {
|
||||
crate::tui::list_nav::apply(
|
||||
self.pick_row,
|
||||
self.filtered_routes().len(),
|
||||
ROUTE_PICK_PAGE,
|
||||
m,
|
||||
)
|
||||
})
|
||||
.is_some_and(|row| {
|
||||
self.pick_row = row;
|
||||
true
|
||||
}) =>
|
||||
{
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Enter => self.apply_route_pick().unwrap_or(ViewAction::None),
|
||||
@@ -789,12 +794,20 @@ impl ModalView for FleetDetailView {
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.move_row(-1);
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.move_row(1);
|
||||
_ if crate::tui::list_nav::motion(&key)
|
||||
.and_then(|m| {
|
||||
crate::tui::list_nav::apply(
|
||||
self.selected,
|
||||
self.row_count(),
|
||||
DETAIL_PAGE,
|
||||
m,
|
||||
)
|
||||
})
|
||||
.is_some_and(|row| {
|
||||
self.selected = row;
|
||||
true
|
||||
}) =>
|
||||
{
|
||||
ViewAction::None
|
||||
}
|
||||
// Enter opens the standard `/model` picker for the row —
|
||||
|
||||
Reference in new issue
Block a user