refactor: extract ListNav component, unify list key handling across views

This commit is contained in:
2026-05-27 10:41:58 +08:00
parent 0d65be65d3
commit 01c8f7f8fc
5 changed files with 86 additions and 85 deletions
+2
View File
@@ -3,6 +3,7 @@ pub mod dialog;
pub mod form_list; pub mod form_list;
pub mod input; pub mod input;
pub mod layout; pub mod layout;
pub mod list_nav;
pub mod panel; pub mod panel;
pub mod toast; pub mod toast;
@@ -11,5 +12,6 @@ pub use dialog::*;
pub use form_list::{FormRow, draw_form_list}; pub use form_list::{FormRow, draw_form_list};
pub use input::draw_input; pub use input::draw_input;
pub use layout::*; pub use layout::*;
pub use list_nav::{ListAction, handle_list_nav};
pub use panel::*; pub use panel::*;
pub use toast::draw_toast; pub use toast::draw_toast;
+23
View File
@@ -0,0 +1,23 @@
use crossterm::event::{KeyCode, KeyEvent};
pub enum ListAction {
None,
Cancel,
Select,
}
pub fn handle_list_nav(cursor: &mut usize, len: usize, key: KeyEvent) -> ListAction {
match key.code {
KeyCode::Down | KeyCode::Char('j') if len > 0 => {
*cursor = (*cursor + 1) % len;
ListAction::None
}
KeyCode::Up | KeyCode::Char('k') if len > 0 => {
*cursor = (*cursor + len - 1) % len;
ListAction::None
}
KeyCode::Esc => ListAction::Cancel,
KeyCode::Enter => ListAction::Select,
_ => ListAction::None,
}
}
+8 -12
View File
@@ -1,4 +1,5 @@
use crate::app::{App, Mode}; use crate::app::{App, Mode};
use crate::ui::component::{ListAction, handle_list_nav};
use crate::ui::{ACCENT, MUTED, PANEL, SELECTED_BG, TEXT}; use crate::ui::{ACCENT, MUTED, PANEL, SELECTED_BG, TEXT};
use super::View; use super::View;
@@ -82,20 +83,15 @@ impl View for ActionMenuView {
} }
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
let len = ACTIONS.len(); if key.code == KeyCode::Char(':') {
match key.code { app.session.mode = Mode::Home;
KeyCode::Char(':') | KeyCode::Esc => { return Ok(());
}
match handle_list_nav(&mut app.session.action_menu.cursor, ACTIONS.len(), key) {
ListAction::Cancel => {
app.session.mode = Mode::Home; app.session.mode = Mode::Home;
} }
KeyCode::Down | KeyCode::Char('j') => { ListAction::Select => {
app.session.action_menu.cursor =
(app.session.action_menu.cursor + 1) % len;
}
KeyCode::Up | KeyCode::Char('k') => {
app.session.action_menu.cursor =
(app.session.action_menu.cursor + len - 1) % len;
}
KeyCode::Enter => {
app.session.mode = Mode::Home; app.session.mode = Mode::Home;
match app.session.action_menu.cursor { match app.session.action_menu.cursor {
0 => app.enter_combined_import()?, 0 => app.enter_combined_import()?,
+12 -29
View File
@@ -1,6 +1,6 @@
use crate::app::{App, Mode}; use crate::app::{App, Mode};
use crate::config::CredentialEntry; use crate::config::CredentialEntry;
use crate::ui::component::panel; use crate::ui::component::{ListAction, handle_list_nav, panel};
use crate::ui::{BLUE, GREEN, MUTED, RED, SELECTED_BG, TEXT}; use crate::ui::{BLUE, GREEN, MUTED, RED, SELECTED_BG, TEXT};
use super::{View, scroll_rows}; use super::{View, scroll_rows};
@@ -118,35 +118,18 @@ fn draw_credentials(frame: &mut Frame<'_>, app: &App, area: Rect) {
// ── Key handling ─────────────────────────────────────────────── // ── Key handling ───────────────────────────────────────────────
fn handle_credentials(app: &mut App, key: KeyEvent) -> Result<()> { fn handle_credentials(app: &mut App, key: KeyEvent) -> Result<()> {
match key.code { let len = app.config.credentials.entries.len();
KeyCode::Esc => app.session.mode = Mode::Home, match handle_list_nav(&mut app.session.credentials.selected, len, key) {
KeyCode::Down | KeyCode::Char('j') => { ListAction::Cancel => app.session.mode = Mode::Home,
let len = app.config.credentials.entries.len(); ListAction::Select => app.edit_cred_form(),
if len > 0 { ListAction::None => match key.code {
app.session.credentials.selected = (app.session.credentials.selected as isize + 1) KeyCode::Char('a') => app.new_cred_form(),
.rem_euclid(len as isize) KeyCode::Char('d') => match app.delete_cred() {
as usize; Ok(()) => app.toast("deleted", true),
} Err(err) => app.toast(err.to_string(), false),
} },
KeyCode::Up | KeyCode::Char('k') => { _ => {}
let len = app.config.credentials.entries.len();
if len > 0 {
app.session.credentials.selected = (app.session.credentials.selected as isize - 1)
.rem_euclid(len as isize)
as usize;
}
}
KeyCode::Enter => {
app.edit_cred_form();
}
KeyCode::Char('a') => {
app.new_cred_form();
}
KeyCode::Char('d') => match app.delete_cred() {
Ok(()) => app.toast("deleted", true),
Err(err) => app.toast(err.to_string(), false),
}, },
_ => {}
} }
Ok(()) Ok(())
} }
+41 -44
View File
@@ -1,5 +1,5 @@
use crate::app::App; use crate::app::App;
use crate::ui::component::{panel, panel_with_subtitle}; use crate::ui::component::{ListAction, handle_list_nav, panel, panel_with_subtitle};
use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT}; use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
use super::View; use super::View;
@@ -189,48 +189,9 @@ fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> {
let shell_len = app.session.import.shell_candidates.len(); let shell_len = app.session.import.shell_candidates.len();
let total = shell_len + app.session.import.candidates.len(); let total = shell_len + app.session.import.candidates.len();
match key.code { match handle_list_nav(&mut app.session.import.cursor, total, key) {
KeyCode::Esc => app.session.mode = crate::app::Mode::Home, ListAction::Cancel => app.session.mode = crate::app::Mode::Home,
KeyCode::Down | KeyCode::Char('j') if total > 0 => { ListAction::Select => {
app.session.import.cursor = (app.session.import.cursor + 1) % total;
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
app.session.import.cursor = (app.session.import.cursor + total - 1) % total;
}
KeyCode::Char(' ') => {
if app.session.import.cursor < shell_len {
let can_toggle = app
.session
.import
.shell_candidates
.get(app.session.import.cursor)
.is_some_and(|c| c.conflict.is_none());
if can_toggle
&& let Some(v) = app.session.import.shell_selected.get_mut(app.session.import.cursor)
{
*v = !*v;
}
} else {
let ssh_idx = app.session.import.cursor - shell_len;
if let Some(v) = app.session.import.selected.get_mut(ssh_idx) {
*v = !*v;
}
}
}
KeyCode::Char('a') => {
app.session
.import
.shell_selected
.iter_mut()
.zip(&app.session.import.shell_candidates)
.for_each(|(sel, c)| *sel = c.conflict.is_none());
app.session.import.selected.fill(true);
}
KeyCode::Char('A') => {
app.session.import.shell_selected.fill(false);
app.session.import.selected.fill(false);
}
KeyCode::Enter => {
let mut imported = 0; let mut imported = 0;
let picked_shells: Vec<_> = app let picked_shells: Vec<_> = app
.session .session
@@ -272,7 +233,43 @@ fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> {
} }
app.session.mode = crate::app::Mode::Home; app.session.mode = crate::app::Mode::Home;
} }
_ => {} ListAction::None => match key.code {
KeyCode::Char(' ') => {
if app.session.import.cursor < shell_len {
let can_toggle = app
.session
.import
.shell_candidates
.get(app.session.import.cursor)
.is_some_and(|c| c.conflict.is_none());
if can_toggle
&& let Some(v) =
app.session.import.shell_selected.get_mut(app.session.import.cursor)
{
*v = !*v;
}
} else {
let ssh_idx = app.session.import.cursor - shell_len;
if let Some(v) = app.session.import.selected.get_mut(ssh_idx) {
*v = !*v;
}
}
}
KeyCode::Char('a') => {
app.session
.import
.shell_selected
.iter_mut()
.zip(&app.session.import.shell_candidates)
.for_each(|(sel, c)| *sel = c.conflict.is_none());
app.session.import.selected.fill(true);
}
KeyCode::Char('A') => {
app.session.import.shell_selected.fill(false);
app.session.import.selected.fill(false);
}
_ => {}
},
} }
Ok(()) Ok(())
} }