363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
use ratatui::{
|
|
layout::{Constraint, Direction, Layout},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span, Text},
|
|
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
|
Frame,
|
|
};
|
|
|
|
use crate::app::{App, CustomFocus, Focus, Screen};
|
|
|
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
|
match app.screen {
|
|
Screen::Main => draw_main(f, app),
|
|
Screen::Custom => draw_custom(f, app),
|
|
Screen::Search => draw_search(f, app),
|
|
}
|
|
}
|
|
|
|
fn draw_main(f: &mut Frame, app: &mut App) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.margin(1)
|
|
.constraints([
|
|
Constraint::Min(8),
|
|
Constraint::Percentage(40),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(f.area());
|
|
|
|
let top_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(25),
|
|
Constraint::Percentage(25),
|
|
Constraint::Percentage(50),
|
|
])
|
|
.split(chunks[0]);
|
|
|
|
draw_groups(f, app, top_chunks[0]);
|
|
draw_files(f, app, top_chunks[1]);
|
|
draw_sections(f, app, top_chunks[2]);
|
|
draw_preview(f, app, chunks[1]);
|
|
draw_status(f, app, chunks[2]);
|
|
}
|
|
|
|
fn draw_custom(f: &mut Frame, app: &mut App) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.margin(1)
|
|
.constraints([
|
|
Constraint::Min(5),
|
|
Constraint::Length(5),
|
|
Constraint::Length(5),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(f.area());
|
|
|
|
let Some(state) = app.custom_state.as_mut() else {
|
|
return;
|
|
};
|
|
|
|
let items: Vec<ListItem> = state
|
|
.lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, line)| {
|
|
let checked = if state.selected.get(i).copied().unwrap_or(false) {
|
|
"[x]"
|
|
} else {
|
|
"[ ]"
|
|
};
|
|
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);
|
|
}
|
|
ListItem::new(label).style(style)
|
|
})
|
|
.collect();
|
|
|
|
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);
|
|
state.list_state.select(Some(state.cursor));
|
|
f.render_stateful_widget(list, chunks[0], &mut state.list_state);
|
|
|
|
let input = Paragraph::new(state.command.as_str())
|
|
.block(
|
|
Block::default()
|
|
.title(" Command (use {} for selected line) ")
|
|
.borders(Borders::ALL)
|
|
.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 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[3]);
|
|
}
|
|
|
|
fn draw_search(f: &mut Frame, app: &mut App) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.margin(1)
|
|
.constraints([
|
|
Constraint::Length(3),
|
|
Constraint::Min(5),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(f.area());
|
|
|
|
let Some(state) = app.search_state.as_mut() else {
|
|
return;
|
|
};
|
|
|
|
let input = Paragraph::new(state.query.as_str())
|
|
.block(
|
|
Block::default()
|
|
.title(" Search ")
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Yellow)),
|
|
)
|
|
.wrap(Wrap { trim: true });
|
|
f.render_widget(input, chunks[0]);
|
|
|
|
let items: Vec<ListItem> = state
|
|
.results
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, result)| {
|
|
let style = if i == state.cursor {
|
|
Style::default()
|
|
.bg(Color::Blue)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let label = if result.command.is_empty() {
|
|
format!("[{}] {} — {}", result.group, result.file, result.section)
|
|
} else {
|
|
format!(
|
|
"[{}] {} — {} — {}",
|
|
result.group, result.file, result.section, result.command
|
|
)
|
|
};
|
|
ListItem::new(label).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items).block(
|
|
Block::default()
|
|
.title(format!(" Results ({}) ", state.results.len()))
|
|
.borders(Borders::ALL),
|
|
);
|
|
state.list_state.select(Some(state.cursor));
|
|
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
|
|
|
let preview_text = state
|
|
.results
|
|
.get(state.cursor)
|
|
.map(|r| Text::from(r.command.as_str()))
|
|
.unwrap_or_else(|| Text::from("No result selected."));
|
|
let preview = Paragraph::new(preview_text)
|
|
.block(Block::default().title(" Preview ").borders(Borders::ALL))
|
|
.wrap(Wrap { trim: true });
|
|
f.render_widget(preview, chunks[2]);
|
|
}
|
|
|
|
fn draw_groups(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
|
|
let items: Vec<ListItem> = app
|
|
.groups
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, g)| {
|
|
let style = if i == app.group_index {
|
|
Style::default()
|
|
.bg(Color::Blue)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
ListItem::new(g.name.clone()).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let block = Block::default()
|
|
.title(" Groups ")
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style(app.focus == Focus::Groups));
|
|
|
|
let list = List::new(items).block(block);
|
|
app.group_list_state.select(Some(app.group_index));
|
|
f.render_stateful_widget(list, area, &mut app.group_list_state);
|
|
}
|
|
|
|
fn draw_files(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
|
|
let items: Vec<ListItem> = app
|
|
.current_files()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, file)| {
|
|
let style = if i == app.file_index {
|
|
Style::default()
|
|
.bg(Color::Blue)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
ListItem::new(file.name.clone()).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let block = Block::default()
|
|
.title(" Files ")
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style(app.focus == Focus::Files));
|
|
|
|
let list = List::new(items).block(block);
|
|
app.file_list_state.select(Some(app.file_index));
|
|
f.render_stateful_widget(list, area, &mut app.file_list_state);
|
|
}
|
|
|
|
fn draw_sections(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
|
|
let items: Vec<ListItem> = app
|
|
.current_sections()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, section)| {
|
|
let label = if section.is_run_all() {
|
|
format!("{} (all)", section.title)
|
|
} else {
|
|
section.title.clone()
|
|
};
|
|
let style = if i == app.section_index {
|
|
Style::default()
|
|
.bg(Color::Blue)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
ListItem::new(label).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let block = Block::default()
|
|
.title(" Commands ")
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style(app.focus == Focus::Sections));
|
|
|
|
let list = List::new(items).block(block);
|
|
app.section_list_state.select(Some(app.section_index));
|
|
f.render_stateful_widget(list, area, &mut app.section_list_state);
|
|
}
|
|
|
|
fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|
let text = match app.current_section() {
|
|
Some(section) => {
|
|
let title = if section.is_run_all() {
|
|
format!("{} — runs all commands in this file", section.title)
|
|
} else {
|
|
section.title.clone()
|
|
};
|
|
|
|
let mut lines = vec![Line::from(Span::styled(
|
|
title,
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
))];
|
|
|
|
if section.is_run_all() {
|
|
if let Some(file) = app.current_file() {
|
|
for sec in &file.sections {
|
|
if sec.is_run_all() {
|
|
continue;
|
|
}
|
|
lines.push(Line::from(Span::styled(
|
|
format!("# {}", sec.title),
|
|
Style::default().fg(Color::Cyan),
|
|
)));
|
|
for cmd in &sec.commands {
|
|
lines.push(Line::from(cmd.as_str()));
|
|
}
|
|
lines.push(Line::from(""));
|
|
}
|
|
}
|
|
} else {
|
|
for cmd in §ion.commands {
|
|
lines.push(Line::from(cmd.as_str()));
|
|
}
|
|
}
|
|
|
|
Text::from(lines)
|
|
}
|
|
None => Text::from("No command to display."),
|
|
};
|
|
|
|
let block = Block::default()
|
|
.title(" Preview ")
|
|
.borders(Borders::ALL);
|
|
|
|
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|
let help = "↑/↓: move | →/←: panel | Enter: run | /: search | 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),
|
|
};
|
|
let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray));
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn border_style(focused: bool) -> Style {
|
|
if focused {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
}
|
|
}
|