diff --git a/Cargo.lock b/Cargo.lock index bc512ba..76fbe3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,7 +825,7 @@ dependencies = [ [[package]] name = "lines" -version = "0.1.16" +version = "0.1.17" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 9ffe343..cafa17b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lines" -version = "0.1.16" +version = "0.1.17" edition = "2024" authors = ["janvete"] description = "TUI for running frequently used commands" diff --git a/src/app.rs b/src/app.rs index b931a69..b6a74a9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,7 @@ pub struct PendingCommand { pub enum CustomFocus { Lines, Input, + PreCommand, } #[derive(Debug, Clone)] @@ -55,6 +56,7 @@ pub struct CustomState { pub cursor: usize, pub focus: CustomFocus, pub command: String, + pub pre_command: String, pub list_state: ListState, } @@ -81,6 +83,7 @@ impl CustomState { cursor: 0, focus: CustomFocus::Lines, command: String::new(), + pre_command: String::new(), list_state, } } @@ -115,12 +118,12 @@ impl CustomState { .collect() } - pub fn focus_input(&mut self) { - self.focus = CustomFocus::Input; - } - - pub fn focus_lines(&mut self) { - self.focus = CustomFocus::Lines; + pub fn cycle_focus(&mut self) { + self.focus = match self.focus { + CustomFocus::Lines => CustomFocus::Input, + CustomFocus::Input => CustomFocus::PreCommand, + CustomFocus::PreCommand => CustomFocus::Lines, + }; } pub fn toggle_all(&mut self) { diff --git a/src/custom.rs b/src/custom.rs index 3e57c19..b803265 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -11,15 +11,23 @@ pub fn build_commands(state: &CustomState) -> Vec { return Vec::new(); } + let pre_command = state.pre_command.trim(); + let prefix = if pre_command.is_empty() { + String::new() + } else { + format!("{} ", pre_command) + }; + selected .iter() .map(|line| { let line = line.trim(); - if command.contains("{}") { + let built = if command.contains("{}") { command.replace("{}", line) } else { format!("{} \"{}\"", line, command.replace('"', "\\\"")) - } + }; + format!("{}{}", prefix, built) }) .collect() } @@ -29,7 +37,7 @@ mod tests { use super::*; use crate::app::{CustomLine, CustomState}; - fn make_state(lines: Vec<&str>, selected: Vec, command: &str) -> CustomState { + fn make_state(lines: Vec<&str>, selected: Vec, command: &str, pre_command: &str) -> CustomState { let mut list_state = ratatui::widgets::ListState::default(); list_state.select(Some(0)); CustomState { @@ -44,6 +52,7 @@ mod tests { cursor: 0, focus: crate::app::CustomFocus::Lines, command: command.to_string(), + pre_command: pre_command.to_string(), list_state, } } @@ -54,6 +63,7 @@ mod tests { vec!["ssh root@ip1", "ssh root@ip2", "ssh root@ip3"], vec![false, true, true], "lsblk", + "", ); assert_eq!( build_commands(&state), @@ -63,7 +73,7 @@ mod tests { #[test] fn test_build_commands_placeholder() { - let state = make_state(vec!["root@ip1", "root@ip2"], 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"] @@ -72,7 +82,38 @@ mod tests { #[test] fn test_build_commands_empty_selection() { - let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk"); + let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk", ""); assert!(build_commands(&state).is_empty()); } + + #[test] + fn test_build_commands_pre_command() { + let state = make_state( + vec!["ssh root@ip1", "ssh root@ip2"], + vec![true, true], + "ping", + "lview", + ); + assert_eq!( + build_commands(&state), + vec![ + "lview ssh root@ip1 \"ping\"", + "lview ssh root@ip2 \"ping\"" + ] + ); + } + + #[test] + fn test_build_commands_pre_command_placeholder() { + let state = make_state( + vec!["root@ip1", "root@ip2"], + vec![true, true], + "ssh {} lsblk", + "lview", + ); + assert_eq!( + build_commands(&state), + vec!["lview ssh root@ip1 lsblk", "lview ssh root@ip2 lsblk"] + ); + } } diff --git a/src/events.rs b/src/events.rs index e9ff25b..3c0be0c 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,21 +1,23 @@ -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, MouseEventKind}; use std::io; use std::time::Duration; -use crate::app::{App, CustomFocus, PendingCommand, Screen}; +use crate::app::{App, CustomFocus, CustomState, PendingCommand, Screen}; use crate::commands::{edit_file, run_section}; use crate::custom::build_commands; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone)] pub enum AppEvent { Tick, Key(event::KeyEvent), + Mouse(event::MouseEvent), } pub fn poll_event(timeout: Duration) -> io::Result> { if event::poll(timeout)? { match event::read()? { Event::Key(key) => Ok(Some(AppEvent::Key(key))), + Event::Mouse(mouse) => Ok(Some(AppEvent::Mouse(mouse))), _ => Ok(Some(AppEvent::Tick)), } } else { @@ -37,6 +39,49 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool { Screen::Search => handle_search_event(app, key), } } + AppEvent::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollDown => { + scroll_down(app); + true + } + MouseEventKind::ScrollUp => { + scroll_up(app); + true + } + _ => true, + }, + } +} + +fn scroll_down(app: &mut App) { + match app.screen { + Screen::Main => app.next(), + Screen::Custom => { + if let Some(state) = app.custom_state.as_mut() { + state.next(); + } + } + Screen::Search => { + if let Some(state) = app.search_state.as_mut() && !state.results.is_empty() { + state.cursor = (state.cursor + 1) % state.results.len(); + } + } + } +} + +fn scroll_up(app: &mut App) { + match app.screen { + Screen::Main => app.previous(), + Screen::Custom => { + if let Some(state) = app.custom_state.as_mut() { + state.previous(); + } + } + Screen::Search => { + if let Some(state) = app.search_state.as_mut() && !state.results.is_empty() { + state.cursor = state.cursor.checked_sub(1).unwrap_or(state.results.len() - 1); + } + } } } @@ -115,11 +160,7 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool { app.exit_custom(); } KeyCode::Tab => { - if state.focus == CustomFocus::Lines { - state.focus_input(); - } else { - state.focus_lines(); - } + state.cycle_focus(); } KeyCode::Down => { if state.focus == CustomFocus::Lines { @@ -135,21 +176,21 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool { if state.focus == CustomFocus::Lines { state.toggle(); } else { - state.command.push(' '); + push_to_active_input(state, ' '); } } KeyCode::Char('a') => { if state.focus == CustomFocus::Lines { state.toggle_all(); } else { - state.command.push('a'); + push_to_active_input(state, 'a'); } } KeyCode::Backspace => { - state.command.pop(); + pop_from_active_input(state); } KeyCode::Enter => { - if state.focus == CustomFocus::Input { + if state.focus == CustomFocus::Input || state.focus == CustomFocus::PreCommand { let commands = build_commands(state); if !commands.is_empty() && let (Some(file), Some(group)) = (app.current_file(), app.current_group()) @@ -163,17 +204,41 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool { return false; } } else { - state.focus_input(); + state.focus = CustomFocus::Input; } } KeyCode::Char(c) => { - state.command.push(c); + if state.focus == CustomFocus::Lines { + state.command.push(c); + } else { + push_to_active_input(state, c); + } } _ => {} } true } +fn push_to_active_input(state: &mut CustomState, c: char) { + match state.focus { + CustomFocus::Input => state.command.push(c), + CustomFocus::PreCommand => state.pre_command.push(c), + CustomFocus::Lines => {} + } +} + +fn pop_from_active_input(state: &mut CustomState) { + match state.focus { + CustomFocus::Input => { + state.command.pop(); + } + CustomFocus::PreCommand => { + state.pre_command.pop(); + } + CustomFocus::Lines => {} + } +} + fn handle_search_event(app: &mut App, key: event::KeyEvent) -> bool { let Some(state) = app.search_state.as_mut() else { app.exit_search(); diff --git a/src/ui.rs b/src/ui.rs index ea83219..ae234dd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -50,6 +50,7 @@ fn draw_custom(f: &mut Frame, app: &mut App) { .constraints([ Constraint::Min(5), Constraint::Length(5), + Constraint::Length(5), Constraint::Length(1), ]) .split(f.area()); @@ -111,13 +112,27 @@ fn draw_custom(f: &mut Frame, app: &mut App) { .wrap(Wrap { trim: true }); f.render_widget(input, chunks[1]); - let help = "↑/↓: move | Space: toggle | a: select all | Tab: focus | Enter: run | Esc: back"; + let pre_command = Paragraph::new(state.pre_command.as_str()) + .block( + Block::default() + .title(" Pre-command ") + .borders(Borders::ALL) + .border_style(if state.focus == CustomFocus::PreCommand { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }), + ) + .wrap(Wrap { trim: true }); + f.render_widget(pre_command, chunks[2]); + + let help = "↑/↓: move | Space: toggle | a: select all | Tab: focus | Enter: run | Esc: back | scroll: move"; let text = match &app.message { Some(msg) => Text::from(msg.as_str()), None => Text::from(help), }; let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray)); - f.render_widget(paragraph, chunks[2]); + f.render_widget(paragraph, chunks[3]); } fn draw_search(f: &mut Frame, app: &mut App) {