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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ use ratatui::{
Frame, Frame,
}; };
use crate::app::{App, Focus, Screen}; use crate::app::{App, CustomFocus, Focus, Screen};
pub fn draw(f: &mut Frame, app: &App) { pub fn draw(f: &mut Frame, app: &App) {
match app.screen { match app.screen {
@ -67,23 +67,32 @@ fn draw_custom(f: &mut Frame, app: &App) {
} else { } else {
"[ ]" "[ ]"
}; };
let style = if i == state.cursor { let label = format!(
Style::default() "{} {} — {}",
checked,
line.command,
line.section
);
let mut style = Style::default();
if i == state.cursor && state.focus == CustomFocus::Lines {
style = style
.bg(Color::Blue) .bg(Color::Blue)
.fg(Color::Black) .fg(Color::Black)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD);
} else { }
Style::default() ListItem::new(label).style(style)
};
ListItem::new(format!("{} {}", checked, line)).style(style)
}) })
.collect(); .collect();
let list = List::new(items).block( let list_block = Block::default()
Block::default() .title(" Custom command — select lines with Space ")
.title(" Custom command — select lines with Space ") .borders(Borders::ALL)
.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]); f.render_widget(list, chunks[0]);
let input = Paragraph::new(state.command.as_str()) let input = Paragraph::new(state.command.as_str())
@ -91,12 +100,16 @@ fn draw_custom(f: &mut Frame, app: &App) {
Block::default() Block::default()
.title(" Command (use {} for selected line) ") .title(" Command (use {} for selected line) ")
.borders(Borders::ALL) .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 }); .wrap(Wrap { trim: true });
f.render_widget(input, chunks[1]); 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 { let text = match &app.message {
Some(msg) => Text::from(msg.as_str()), Some(msg) => Text::from(msg.as_str()),
None => Text::from(help), 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) { 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 { let text = match &app.message {
Some(msg) => Text::from(msg.as_str()), Some(msg) => Text::from(msg.as_str()),
None => Text::from(help), None => Text::from(help),