Initial commit
This commit is contained in:
commit
296b987e3b
14 files changed
+4035
No files matched your search
+463
@@ -0,0 +1,463 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user