diff --git a/src/app.rs b/src/app.rs index 7d8e558..975580f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,11 +17,24 @@ pub struct PendingCommand { pub commands: Vec, } +#[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, + pub lines: Vec, pub selected: Vec, pub cursor: usize, + pub focus: CustomFocus, pub command: String, } @@ -33,7 +46,10 @@ impl CustomState { continue; } for cmd in §ion.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)] diff --git a/src/custom.rs b/src/custom.rs index 71fccd4..c455c83 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -27,13 +27,20 @@ pub fn build_commands(state: &CustomState) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::app::CustomState; + use crate::app::{CustomLine, CustomState}; - fn make_state(lines: Vec, selected: Vec, command: &str) -> CustomState { + fn make_state(lines: Vec<&str>, selected: Vec, 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()); } } diff --git a/src/events.rs b/src/events.rs index 5649e38..42f482b 100644 --- a/src/events.rs +++ b/src/events.rs @@ -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) => { diff --git a/src/ui.rs b/src/ui.rs index 401339d..fd95e56 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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),