Files
Ratbin/src/tui.rs
T
2026-10-15 16:22:00 +03:00

464 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{io, path::PathBuf};
use anyhow::Result;
use ratatui::{
DefaultTerminal, Frame,
crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::SetTitle,
},
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::store::{self, Fields, Integration};
const LABELS: [&str; 6] = [
"Name",
"Comment",
"Categories",
"Icon",
"Arguments",
"Terminal",
];
const TERMINAL_FIELD: usize = 5;
const LABEL_WIDTH: u16 = 12;
pub fn run() -> Result<()> {
let mut app = App::new()?;
let mut terminal = ratatui::init();
let _ = execute!(io::stdout(), SetTitle("Ratbin"));
let result = app.run(&mut terminal);
ratatui::restore();
result
}
enum Mode {
Browse,
Edit(Form),
Confirm,
}
struct App {
items: Vec<Integration>,
list: ListState,
mode: Mode,
status: Option<(String, bool)>,
quit: bool,
}
impl App {
fn new() -> Result<Self> {
let mut app = Self {
items: Vec::new(),
list: ListState::default(),
mode: Mode::Browse,
status: None,
quit: false,
};
app.reload(None)?;
Ok(app)
}
fn reload(&mut self, keep: Option<PathBuf>) -> Result<()> {
self.items = store::list()?;
let index = keep
.and_then(|path| self.items.iter().position(|i| i.desktop_path == path))
.or(self.list.selected())
.unwrap_or(0)
.min(self.items.len().saturating_sub(1));
self.list.select((!self.items.is_empty()).then_some(index));
Ok(())
}
fn selected(&self) -> Option<&Integration> {
self.items.get(self.list.selected()?)
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
terminal.draw(|frame| self.draw(frame))?;
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
self.status = None;
self.on_key(key);
}
}
Ok(())
}
fn on_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match &mut self.mode {
Mode::Browse => self.on_browse(key),
Mode::Confirm => self.on_confirm(key),
Mode::Edit(form) => match key.code {
KeyCode::Esc => self.mode = Mode::Browse,
KeyCode::Enter => self.save(),
KeyCode::Char('s') if ctrl => self.save(),
_ => form.on_key(key),
},
}
}
fn on_browse(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
KeyCode::Down | KeyCode::Char('j') => self.list.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.list.select_previous(),
KeyCode::Home | KeyCode::Char('g') => self.list.select_first(),
KeyCode::End | KeyCode::Char('G') => self.list.select_last(),
KeyCode::Enter | KeyCode::Char('e') => {
if let Some(item) = self.selected() {
self.mode = Mode::Edit(Form::new(item.fields()));
}
}
KeyCode::Delete | KeyCode::Char('d') => {
if self.selected().is_some() {
self.mode = Mode::Confirm;
}
}
KeyCode::Char('r') => {
let result = self.reload(None);
self.report(result.map(|()| "Reloaded".into()));
}
_ => {}
}
}
fn on_confirm(&mut self, key: KeyEvent) {
self.mode = Mode::Browse;
let delete_file = match key.code {
KeyCode::Char('e') => false,
KeyCode::Char('d') => true,
_ => return,
};
let Some(item) = self.selected() else {
return;
};
let name = item.name();
let result = store::remove(item, delete_file).and_then(|()| self.reload(None));
self.report(result.map(|()| format!("Removed {name}")));
}
fn save(&mut self) {
let Mode::Edit(form) = std::mem::replace(&mut self.mode, Mode::Browse) else {
return;
};
let Some(item) = self
.list
.selected()
.and_then(|index| self.items.get_mut(index))
else {
return;
};
item.apply(&form.fields());
let path = item.desktop_path.clone();
let name = item.name();
match store::save(item) {
Ok(()) => {
let result = self.reload(Some(path));
self.report(result.map(|()| format!("Saved {name}")));
}
Err(e) => {
self.status = Some((format!("{e:#}"), true));
self.mode = Mode::Edit(form);
}
}
}
fn report(&mut self, result: Result<String>) {
self.status = Some(match result {
Ok(message) => (message, false),
Err(e) => (format!("{e:#}"), true),
});
}
fn draw(&mut self, frame: &mut Frame) {
let [main, footer] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area());
let [left, right] =
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.areas(main);
let items: Vec<ListItem> = self
.items
.iter()
.map(|item| {
let name_style = if item.source().exists() {
Style::default()
} else {
Style::default().fg(Color::Red)
};
ListItem::new(Line::from(vec![
Span::styled(item.name(), name_style),
Span::styled(
format!(" {}", item.kind()),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect();
let list = List::new(items)
.block(Block::bordered().title(" ratbin "))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("› ");
frame.render_stateful_widget(list, left, &mut self.list);
frame.render_widget(self.details(), right);
frame.render_widget(self.footer(), footer);
match &self.mode {
Mode::Browse => {}
Mode::Edit(form) => form.draw(frame, main),
Mode::Confirm => self.draw_confirm(frame, main),
}
}
fn details(&self) -> Paragraph<'static> {
let block = Block::bordered().title(" Details ");
let Some(item) = self.selected() else {
return Paragraph::new(
"Nothing integrated yet.\n\nRight-click an AppImage, binary or script in Thunar \
and choose \"Integrate into menu\".",
)
.block(block)
.wrap(Wrap { trim: false });
};
let source = item.source();
let get = |key: &str| item.entry.get(key).unwrap_or_default();
let row = |label: &str, value: String| {
Line::from(vec![
Span::styled(
format!("{label:<width$}", width = LABEL_WIDTH as usize),
Style::default().fg(Color::Cyan),
),
Span::raw(value),
])
};
let mut lines = vec![
row("Name", item.name()),
row("Kind", item.kind()),
row("Comment", get("Comment")),
row("Categories", get("Categories")),
row("Icon", get("Icon")),
row("Terminal", get("Terminal")),
row("Exec", get("Exec")),
row("File", source.display().to_string()),
row("Entry", item.desktop_path.display().to_string()),
];
if !source.exists() {
lines.push(Line::default());
lines.push(Line::styled(
"The file no longer exists, so this menu entry is broken.",
Style::default().fg(Color::Red),
));
}
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
}
fn footer(&self) -> Line<'static> {
if let Some((message, error)) = &self.status {
let color = if *error { Color::Red } else { Color::Green };
return Line::styled(message.clone(), Style::default().fg(color));
}
let hints = match self.mode {
Mode::Browse => "↑↓ select enter edit d remove r reload q quit",
Mode::Edit(_) => "tab/↑↓ field space toggle ctrl+u clear enter save esc cancel",
Mode::Confirm => "e entry only d entry + file esc cancel",
};
Line::styled(hints, Style::default().fg(Color::DarkGray))
}
fn draw_confirm(&self, frame: &mut Frame, area: Rect) {
let Some(item) = self.selected() else {
return;
};
let file_name = item
.source()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let rect = popup(area, 64, 6);
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(vec![
Line::from(format!("Remove \"{}\"?", item.name())),
Line::default(),
Line::from("e remove the menu entry only"),
Line::from(format!("d also delete {file_name}")),
])
.block(
Block::bordered()
.title(" Remove ")
.border_style(Style::default().fg(Color::Red)),
),
rect,
);
}
}
struct Form {
inputs: [Input; 5],
terminal: bool,
focus: usize,
}
impl Form {
fn new(fields: Fields) -> Self {
Self {
inputs: [
fields.name,
fields.comment,
fields.categories,
fields.icon,
fields.args,
]
.map(Input::new),
terminal: fields.terminal,
focus: 0,
}
}
fn fields(&self) -> Fields {
let [name, comment, categories, icon, args] =
self.inputs.each_ref().map(|input| input.value.clone());
Fields {
name,
comment,
categories,
icon,
args,
terminal: self.terminal,
}
}
fn on_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => self.focus = (self.focus + 1) % LABELS.len(),
KeyCode::BackTab | KeyCode::Up => {
self.focus = (self.focus + LABELS.len() - 1) % LABELS.len();
}
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right if self.focus == TERMINAL_FIELD => {
self.terminal = !self.terminal;
}
_ if self.focus == TERMINAL_FIELD => {}
_ => self.inputs[self.focus].on_key(key),
}
}
fn draw(&self, frame: &mut Frame, area: Rect) {
let rect = popup(area, 80, LABELS.len() as u16 + 2);
frame.render_widget(Clear, rect);
let block = Block::bordered().title(" Edit entry ");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let value_width = inner.width.saturating_sub(LABEL_WIDTH) as usize;
for (row, label) in LABELS.iter().enumerate() {
let y = inner.y + row as u16;
if y >= inner.bottom() {
break;
}
let focused = row == self.focus;
let label_style = if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let (text, cursor) = if row == TERMINAL_FIELD {
let mark = if self.terminal { "[x]" } else { "[ ]" };
(format!("{mark} run in a terminal"), None)
} else {
let (text, cursor) = self.inputs[row].view(value_width);
(text, Some(cursor))
};
let line = Line::from(vec![
Span::styled(
format!("{label:<width$}", width = LABEL_WIDTH as usize),
label_style,
),
Span::raw(text),
]);
frame.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
if focused && let Some(cursor) = cursor {
frame.set_cursor_position((inner.x + LABEL_WIDTH + cursor as u16, y));
}
}
}
}
struct Input {
value: String,
cursor: usize,
}
impl Input {
fn new(value: String) -> Self {
let cursor = value.chars().count();
Self { value, cursor }
}
fn byte_index(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map_or(self.value.len(), |(i, _)| i)
}
fn on_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let len = self.value.chars().count();
match key.code {
KeyCode::Char('u') if ctrl => {
self.value.clear();
self.cursor = 0;
}
KeyCode::Char(c) if !ctrl => {
let at = self.byte_index(self.cursor);
self.value.insert(at, c);
self.cursor += 1;
}
KeyCode::Backspace if self.cursor > 0 => {
self.cursor -= 1;
let at = self.byte_index(self.cursor);
self.value.remove(at);
}
KeyCode::Delete if self.cursor < len => {
let at = self.byte_index(self.cursor);
self.value.remove(at);
}
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(len),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = len,
_ => {}
}
}
fn view(&self, width: usize) -> (String, usize) {
let width = width.max(1);
let start = (self.cursor + 1).saturating_sub(width);
let text = self.value.chars().skip(start).take(width).collect();
(text, self.cursor - start)
}
}
fn popup(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}