lines/src/ui.rs
SW 498803fc55 feat(custom): escape variables so they expand on remote host
feat(ui): show command in global search results

chore: bump version to 0.1.19
2026-07-16 12:21:09 +02:00

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 &section.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)
}
}