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, list: ListState, mode: Mode, status: Option<(String, bool)>, quit: bool, } impl App { fn new() -> Result { 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) -> 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) { 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 = 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: 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: 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, ) }