Custom mode: focus between list and input, show section names, remove j/k
This commit is contained in:
parent
55db8dbbdc
commit
bccd4ab65a
4 changed files with 119 additions and 61 deletions
38
src/app.rs
38
src/app.rs
|
|
@ -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 §ion.commands {
|
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()];
|
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)]
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,13 +110,35 @@ 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 => {
|
||||||
|
if state.focus == CustomFocus::Input {
|
||||||
let commands = build_commands(state);
|
let commands = build_commands(state);
|
||||||
if !commands.is_empty()
|
if !commands.is_empty()
|
||||||
&& let (Some(file), Some(group)) = (app.current_file(), app.current_group())
|
&& let (Some(file), Some(group)) = (app.current_file(), app.current_group())
|
||||||
|
|
@ -129,6 +151,9 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool {
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
state.focus_input();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
state.command.push(c);
|
state.command.push(c);
|
||||||
|
|
|
||||||
43
src/ui.rs
43
src/ui.rs
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue