use std::io::Write; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { fn list( &self, out: impl Write, preview_width: u32, include_expired: bool, ) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { fn list( &self, out: impl Write, preview_width: u32, include_expired: bool, ) -> Result<(), StashError> { self .list_entries(out, preview_width, include_expired) .map(|_| ()) } } /// All mutable state for the TUI list view. struct TuiState { /// Total number of entries matching the current filter in the DB. total: usize, /// Global cursor position: index into the full ordered result set. cursor: usize, /// DB offset of `window[0]`, i.e., the first row currently loaded. viewport_offset: usize, /// The loaded slice of entries: `(id, preview, mime)`. window: Vec<(i64, String, String)>, /// How many rows the window holds (== visible list height). window_size: usize, /// Whether the window needs to be re-fetched from the DB. dirty: bool, } impl TuiState { /// Create initial state: count total rows, load the first window. fn new( db: &SqliteClipboardDb, include_expired: bool, window_size: usize, preview_width: u32, ) -> Result { let total = db.count_entries(include_expired)?; let window = if total > 0 { db.fetch_entries_window(include_expired, 0, window_size, preview_width)? } else { Vec::new() }; Ok(Self { total, cursor: 0, viewport_offset: 0, window, window_size, dirty: false, }) } /// Return the cursor position relative to the current window /// (`window[local_cursor]` == the selected entry). #[inline] fn local_cursor(&self) -> usize { self.cursor.saturating_sub(self.viewport_offset) } /// Return the selected `(id, preview, mime)` if any entry is selected. fn selected_entry(&self) -> Option<&(i64, String, String)> { if self.total == 0 { return None; } self.window.get(self.local_cursor()) } /// Move the cursor down by one, wrapping to 0 at the bottom. fn move_down(&mut self) { if self.total == 0 { return; } self.cursor = if self.cursor + 1 >= self.total { 0 } else { self.cursor + 1 }; self.dirty = true; } /// Move the cursor up by one, wrapping to `total - 1` at the top. fn move_up(&mut self) { if self.total == 0 { return; } self.cursor = if self.cursor == 0 { self.total - 1 } else { self.cursor - 1 }; self.dirty = true; } /// Resize the window (e.g. terminal resized). Marks dirty so the /// viewport is reloaded on the next frame. fn resize(&mut self, new_size: usize) { if new_size != self.window_size { self.window_size = new_size; self.dirty = true; } } /// After a delete the total shrinks by one and the cursor may need /// clamping. The caller is responsible for the DB deletion itself. fn on_delete(&mut self) { if self.total == 0 { return; } self.total -= 1; if self.total == 0 { self.cursor = 0; } else if self.cursor >= self.total { self.cursor = self.total - 1; } self.dirty = true; } /// Reload the window from the DB if `dirty` is set or if the cursor /// has drifted outside the currently loaded range. fn sync( &mut self, db: &SqliteClipboardDb, include_expired: bool, preview_width: u32, ) -> Result<(), StashError> { let cursor_out_of_window = self.cursor < self.viewport_offset || self.cursor >= self.viewport_offset + self.window.len().max(1); if !self.dirty && !cursor_out_of_window { return Ok(()); } // Re-anchor the viewport so the cursor sits in the upper half when // scrolling downward, or at a sensible position when wrapping. let half = self.window_size / 2; self.viewport_offset = if self.cursor >= half { (self.cursor - half).min(self.total.saturating_sub(self.window_size)) } else { 0 }; self.window = if self.total > 0 { db.fetch_entries_window( include_expired, self.viewport_offset, self.window_size, preview_width, )? } else { Vec::new() }; self.dirty = false; Ok(()) } } /// Query the maximum id digit-width and maximum mime byte-length across /// all entries. This is pretty damn fast as it touches only index/metadata, /// not blobs. fn global_column_widths( db: &SqliteClipboardDb, include_expired: bool, ) -> Result<(usize, usize), StashError> { let filter = if include_expired { "" } else { "WHERE (is_expired IS NULL OR is_expired = 0)" }; let query = format!( "SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \ COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}" ); let (id_w, mime_w): (i64, i64) = db .conn .query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?))) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok((id_w.max(2) as usize, mime_w.max(8) as usize)) } impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] pub fn list_tui( &self, preview_width: u32, include_expired: bool, ) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, }, execute, terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }, }; use notify_rust::Notification; use ratatui::{ Terminal, backend::CrosstermBackend, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState}, }; use wl_clipboard_rs::copy::{MimeType, Options, Source}; // One-time column-width metadata (no blob reads). let (max_id_width, max_mime_width) = global_column_widths(self, include_expired)?; enable_raw_mode() .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; // Derive initial window size from current terminal height. let initial_height = terminal .size() .map(|r| r.height.saturating_sub(2) as usize) .unwrap_or(24); let initial_height = initial_height.max(1); let mut tui = TuiState::new(self, include_expired, initial_height, preview_width)?; // ratatui ListState; only tracks selection within the *window* slice. let mut list_state = ListState::default(); if tui.total > 0 { list_state.select(Some(0)); } /// Accumulated actions from draining the event queue. struct EventActions { quit: bool, net_down: i64, // positive=down, negative=up, 0=none copy: bool, delete: bool, } /// Drain all pending key events and return what actions to perform. /// Navigation is capped to ±1 per frame to prevent jumpy scrolling when /// the key-repeat rate exceeds the render frame rate. fn drain_events() -> Result { let mut actions = EventActions { quit: false, net_down: 0, copy: false, delete: false, }; while event::poll(std::time::Duration::from_millis(0)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? { if let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { match (key.code, key.modifiers) { (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, (KeyCode::Down | KeyCode::Char('j'), _) => { // Cap at +1 per frame for smooth scrolling if actions.net_down < 1 { actions.net_down += 1; } }, (KeyCode::Up | KeyCode::Char('k'), _) => { // Cap at -1 per frame for smooth scrolling if actions.net_down > -1 { actions.net_down -= 1; } }, (KeyCode::Enter, _) => actions.copy = true, (KeyCode::Char('D'), KeyModifiers::SHIFT) => actions.delete = true, _ => {}, } } } Ok(actions) } let draw_frame = |terminal: &mut Terminal>, tui: &mut TuiState, list_state: &mut ListState, max_id_width: usize, max_mime_width: usize| -> Result<(), StashError> { let term_height = terminal .size() .map(|r| r.height.saturating_sub(2) as usize) .unwrap_or(24) .max(1); tui.resize(term_height); tui.sync(self, include_expired, preview_width)?; if tui.total == 0 { list_state.select(None); } else { list_state.select(Some(tui.local_cursor())); } terminal .draw(|f| { let area = f.area(); let block = Block::default() .title( "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ to delete, q/ESC to quit)", ) .borders(Borders::ALL); let border_width = 2; let highlight_symbol = ">"; let highlight_width = 1; let content_width = area.width as usize - border_width; let min_id_width = 2; let min_mime_width = 6; let min_preview_width = 4; let spaces = 3; let mut id_col = max_id_width.max(min_id_width); let mut mime_col = max_mime_width.max(min_mime_width); let mut preview_col = content_width .saturating_sub(highlight_width) .saturating_sub(id_col) .saturating_sub(mime_col) .saturating_sub(spaces); if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if mime_col > min_mime_width { let reduce = mime_col - min_mime_width; let take = reduce.min(needed); mime_col -= take; preview_col += take; } } if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if id_col > min_id_width { let reduce = id_col - min_id_width; let take = reduce.min(needed); id_col -= take; preview_col += take; } } if preview_col < min_preview_width { preview_col = min_preview_width; } let selected = list_state.selected(); let list_items: Vec = tui .window .iter() .enumerate() .map(|(i, entry)| { let mut preview = String::new(); let mut width = 0; for g in entry.1.graphemes(true) { let g_width = UnicodeWidthStr::width(g); if width + g_width > preview_col { preview.push('…'); break; } preview.push_str(g); width += g_width; } let mut mime = String::new(); let mut mwidth = 0; for g in entry.2.graphemes(true) { let g_width = UnicodeWidthStr::width(g); if mwidth + g_width > mime_col { mime.push('…'); break; } mime.push_str(g); mwidth += g_width; } let mut spans = Vec::new(); let (id, preview, mime) = entry; if Some(i) == selected { spans.push(Span::styled( highlight_symbol, Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( format!("{id:>id_col$}"), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), )); spans.push(Span::raw(" ")); spans.push(Span::styled( format!("{preview:mime_col$}"), Style::default().fg(Color::Green), )); } else { spans.push(Span::raw(" ")); spans.push(Span::raw(format!("{id:>id_col$}"))); spans.push(Span::raw(" ")); spans.push(Span::raw(format!("{preview:mime_col$}"))); } ListItem::new(Line::from(spans)) }) .collect(); let list = List::new(list_items) .block(block) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) .highlight_symbol(""); f.render_stateful_widget(list, area, list_state); }) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok(()) }; // Initial draw. draw_frame( &mut terminal, &mut tui, &mut list_state, max_id_width, max_mime_width, )?; let res = (|| -> Result<(), StashError> { loop { // Block waiting for events, then drain and process all queued input. if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let actions = drain_events()?; if actions.quit { break; } // Apply navigation (capped at ±1 per frame for smooth scrolling). if actions.net_down > 0 { tui.move_down(); } else if actions.net_down < 0 { tui.move_up(); } if actions.delete && let Some(&(id, ..)) = tui.selected_entry() { self .conn .execute( "DELETE FROM clipboard WHERE id = ?1", rusqlite::params![id], ) .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; tui.on_delete(); let _ = Notification::new() .summary("Stash") .body("Deleted entry") .show(); } if actions.copy && let Some(&(id, ..)) = tui.selected_entry() { match self.copy_entry(id) { Ok((new_id, contents, mime)) => { if new_id != id { tui.dirty = true; } let opts = Options::new(); let mime_type = match mime { Some(ref m) if m == "text/plain" => MimeType::Text, Some(ref m) => MimeType::Specific(m.clone().to_owned()), None => MimeType::Text, }; let copy_result = opts.copy(Source::Bytes(contents.clone().into()), mime_type); match copy_result { Ok(()) => { let _ = Notification::new() .summary("Stash") .body("Copied entry to clipboard") .show(); }, Err(e) => { log::error!("Failed to copy entry to clipboard: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to copy to clipboard: {e}")) .show(); }, } }, Err(e) => { log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to fetch entry: {e}")) .show(); }, } } // Redraw once after processing all accumulated input. draw_frame( &mut terminal, &mut tui, &mut list_state, max_id_width, max_mime_width, )?; } } Ok(()) })(); // Ignore errors during terminal restore, as we can't recover here. let _ = disable_raw_mode(); let _ = execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture ); let _ = terminal.show_cursor(); res } }