Custom mode: focus between list and input, show section names, remove j/k

This commit is contained in:
SW 2026-06-15 20:19:57 +02:00
parent 55db8dbbdc
commit bccd4ab65a
4 changed files with 119 additions and 61 deletions

View file

@ -17,11 +17,24 @@ pub struct PendingCommand {
pub commands: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomFocus {
Lines,
Input,
}
#[derive(Debug, Clone)]
pub struct CustomLine {
pub section: String,
pub command: String,
}
#[derive(Debug, Clone)]
pub struct CustomState {
pub lines: Vec<String>,
pub lines: Vec<CustomLine>,
pub selected: Vec<bool>,
pub cursor: usize,
pub focus: CustomFocus,
pub command: String,
}
@ -33,7 +46,10 @@ impl CustomState {
continue;
}
for cmd in &section.commands {
lines.push(cmd.clone());
lines.push(CustomLine {
section: section.title.clone(),
command: cmd.clone(),
});
}
}
let selected = vec![false; lines.len()];
@ -41,24 +57,28 @@ impl CustomState {
lines,
selected,
cursor: 0,
focus: CustomFocus::Lines,
command: String::new(),
}
}
pub fn toggle(&mut self) {
if self.focus != CustomFocus::Lines {
return;
}
if let Some(s) = self.selected.get_mut(self.cursor) {
*s = !*s;
}
}
pub fn next(&mut self) {
if !self.lines.is_empty() {
if !self.lines.is_empty() && self.focus == CustomFocus::Lines {
self.cursor = (self.cursor + 1) % self.lines.len();
}
}
pub fn previous(&mut self) {
if !self.lines.is_empty() {
if !self.lines.is_empty() && self.focus == CustomFocus::Lines {
self.cursor = self.cursor.checked_sub(1).unwrap_or(self.lines.len() - 1);
}
}
@ -68,9 +88,17 @@ impl CustomState {
.iter()
.enumerate()
.filter(|(i, _)| self.selected.get(*i).copied().unwrap_or(false))
.map(|(_, line)| line.clone())
.map(|(_, line)| line.command.clone())
.collect()
}
pub fn focus_input(&mut self) {
self.focus = CustomFocus::Input;
}
pub fn focus_lines(&mut self) {
self.focus = CustomFocus::Lines;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -27,13 +27,20 @@ pub fn build_commands(state: &CustomState) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::CustomState;
use crate::app::{CustomLine, CustomState};
fn make_state(lines: Vec<String>, selected: Vec<bool>, command: &str) -> CustomState {
fn make_state(lines: Vec<&str>, selected: Vec<bool>, command: &str) -> CustomState {
CustomState {
lines,
lines: lines
.into_iter()
.map(|c| CustomLine {
section: "test".to_string(),
command: c.to_string(),
})
.collect(),
selected,
cursor: 0,
focus: crate::app::CustomFocus::Lines,
command: command.to_string(),
}
}
@ -41,11 +48,7 @@ mod tests {
#[test]
fn test_build_commands_append() {
let state = make_state(
vec![
"ssh root@ip1".to_string(),
"ssh root@ip2".to_string(),
"ssh root@ip3".to_string(),
],
vec!["ssh root@ip1", "ssh root@ip2", "ssh root@ip3"],
vec![false, true, true],
"lsblk",
);
@ -57,14 +60,7 @@ mod tests {
#[test]
fn test_build_commands_placeholder() {
let state = make_state(
vec![
"root@ip1".to_string(),
"root@ip2".to_string(),
],
vec![true, true],
"ssh {} lsblk",
);
let state = make_state(vec!["root@ip1", "root@ip2"], vec![true, true], "ssh {} lsblk");
assert_eq!(
build_commands(&state),
vec!["ssh root@ip1 lsblk", "ssh root@ip2 lsblk"]
@ -73,11 +69,7 @@ mod tests {
#[test]
fn test_build_commands_empty_selection() {
let state = make_state(
vec!["ssh root@ip1".to_string()],
vec![false],
"lsblk",
);
let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk");
assert!(build_commands(&state).is_empty());
}
}

View file

@ -2,7 +2,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use std::io;
use std::time::Duration;
use crate::app::{App, PendingCommand, Screen};
use crate::app::{App, CustomFocus, PendingCommand, Screen};
use crate::commands::{edit_file, run_section};
use crate::custom::build_commands;
@ -42,10 +42,10 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
fn handle_main_event(app: &mut App, key: event::KeyEvent) -> bool {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return false,
KeyCode::Char('j') | KeyCode::Down => app.next(),
KeyCode::Char('k') | KeyCode::Up => app.previous(),
KeyCode::Char('h') | KeyCode::Left => app.focus_left(),
KeyCode::Char('l') | KeyCode::Right => app.focus_right(),
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
KeyCode::Left => app.focus_left(),
KeyCode::Right => app.focus_right(),
KeyCode::Char('r') => app.reload(),
KeyCode::Char('e') => {
if let Some(file) = app.current_file() {
@ -110,24 +110,49 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool {
KeyCode::Esc | KeyCode::Char('q') => {
app.exit_custom();
}
KeyCode::Char('j') | KeyCode::Down => state.next(),
KeyCode::Char('k') | KeyCode::Up => state.previous(),
KeyCode::Char(' ') => state.toggle(),
KeyCode::Tab => {
if state.focus == CustomFocus::Lines {
state.focus_input();
} else {
state.focus_lines();
}
}
KeyCode::Down => {
if state.focus == CustomFocus::Lines {
state.next();
}
}
KeyCode::Up => {
if state.focus == CustomFocus::Lines {
state.previous();
}
}
KeyCode::Char(' ') => {
if state.focus == CustomFocus::Lines {
state.toggle();
} else {
state.command.push(' ');
}
}
KeyCode::Backspace => {
state.command.pop();
}
KeyCode::Enter => {
let commands = build_commands(state);
if !commands.is_empty()
&& let (Some(file), Some(group)) = (app.current_file(), app.current_group())
{
app.pending_command = Some(PendingCommand {
group: group.name.clone(),
file: file.name.clone(),
section: "custom".to_string(),
commands,
});
return false;
if state.focus == CustomFocus::Input {
let commands = build_commands(state);
if !commands.is_empty()
&& let (Some(file), Some(group)) = (app.current_file(), app.current_group())
{
app.pending_command = Some(PendingCommand {
group: group.name.clone(),
file: file.name.clone(),
section: "custom".to_string(),
commands,
});
return false;
}
} else {
state.focus_input();
}
}
KeyCode::Char(c) => {

View file

@ -6,7 +6,7 @@ use ratatui::{
Frame,
};
use crate::app::{App, Focus, Screen};
use crate::app::{App, CustomFocus, Focus, Screen};
pub fn draw(f: &mut Frame, app: &App) {
match app.screen {
@ -67,23 +67,32 @@ fn draw_custom(f: &mut Frame, app: &App) {
} else {
"[ ]"
};
let style = if i == state.cursor {
Style::default()
let label = format!(
"{} {} — {}",
checked,
line.command,
line.section
);
let mut style = Style::default();
if i == state.cursor && state.focus == CustomFocus::Lines {
style = style
.bg(Color::Blue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(format!("{} {}", checked, line)).style(style)
.add_modifier(Modifier::BOLD);
}
ListItem::new(label).style(style)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(" Custom command — select lines with Space ")
.borders(Borders::ALL),
);
let list_block = Block::default()
.title(" Custom command — select lines with Space ")
.borders(Borders::ALL)
.border_style(if state.focus == CustomFocus::Lines {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
});
let list = List::new(items).block(list_block);
f.render_widget(list, chunks[0]);
let input = Paragraph::new(state.command.as_str())
@ -91,12 +100,16 @@ fn draw_custom(f: &mut Frame, app: &App) {
Block::default()
.title(" Command (use {} for selected line) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
.border_style(if state.focus == CustomFocus::Input {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}),
)
.wrap(Wrap { trim: true });
f.render_widget(input, chunks[1]);
let help = "Space: toggle | j/k: move | Enter: run | Esc: back";
let help = "↑/↓: move | Tab: focus | Space: toggle / space | Enter: run | Esc: back";
let text = match &app.message {
Some(msg) => Text::from(msg.as_str()),
None => Text::from(help),
@ -243,7 +256,7 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
}
fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let help = "↑/↓ or j/k: move | →/← or h/l: panel | Enter: run | o: new window | c: custom | e: edit | r: reload | q: quit";
let help = "↑/↓: move | →/←: panel | Enter: run | o: new window | c: custom | e: edit | r: reload | q: quit";
let text = match &app.message {
Some(msg) => Text::from(msg.as_str()),
None => Text::from(help),