feat: added tui
2deba90d
5 file(s) · +158 −4
| 3 | 3 | version = "0.1.0" |
|
| 4 | 4 | edition = "2024" |
|
| 5 | 5 | ||
| 6 | + | [[bin]] |
|
| 7 | + | name = "sipp-server" |
|
| 8 | + | path = "src/main.rs" |
|
| 9 | + | ||
| 10 | + | [[bin]] |
|
| 11 | + | name = "sipp-tui" |
|
| 12 | + | path = "src/bin/tui.rs" |
|
| 13 | + | ||
| 6 | 14 | [dependencies] |
|
| 7 | 15 | axum = "0.8.8" |
|
| 8 | 16 | tokio = { version = "1", features = ["full"] } |
|
| 12 | 20 | serde = { version = "1", features = ["derive"] } |
|
| 13 | 21 | tower-http = { version = "0.6.8", features = ["fs"] } |
|
| 14 | 22 | rand = "0.10" |
|
| 23 | + | ratatui = "0.30" |
|
| 24 | + | crossterm = "0.28" |
|
| 25 | + | arboard = "3" |
|
| 1 | + | use arboard::Clipboard; |
|
| 2 | + | use crossterm::event::{self, Event, KeyCode}; |
|
| 3 | + | use ratatui::{ |
|
| 4 | + | DefaultTerminal, |
|
| 5 | + | layout::{Constraint, Layout}, |
|
| 6 | + | style::{Color, Modifier, Style}, |
|
| 7 | + | text::Text, |
|
| 8 | + | widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, |
|
| 9 | + | }; |
|
| 10 | + | use sipp_rust::db::{self, Snippet}; |
|
| 11 | + | ||
| 12 | + | struct App { |
|
| 13 | + | snippets: Vec<Snippet>, |
|
| 14 | + | list_state: ListState, |
|
| 15 | + | should_quit: bool, |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | impl App { |
|
| 19 | + | fn new(snippets: Vec<Snippet>) -> Self { |
|
| 20 | + | let mut list_state = ListState::default(); |
|
| 21 | + | if !snippets.is_empty() { |
|
| 22 | + | list_state.select(Some(0)); |
|
| 23 | + | } |
|
| 24 | + | Self { |
|
| 25 | + | snippets, |
|
| 26 | + | list_state, |
|
| 27 | + | should_quit: false, |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | fn selected_snippet(&self) -> Option<&Snippet> { |
|
| 32 | + | self.list_state.selected().and_then(|i| self.snippets.get(i)) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | fn move_up(&mut self) { |
|
| 36 | + | if self.snippets.is_empty() { |
|
| 37 | + | return; |
|
| 38 | + | } |
|
| 39 | + | let i = match self.list_state.selected() { |
|
| 40 | + | Some(i) if i > 0 => i - 1, |
|
| 41 | + | Some(_) => self.snippets.len() - 1, |
|
| 42 | + | None => 0, |
|
| 43 | + | }; |
|
| 44 | + | self.list_state.select(Some(i)); |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | fn move_down(&mut self) { |
|
| 48 | + | if self.snippets.is_empty() { |
|
| 49 | + | return; |
|
| 50 | + | } |
|
| 51 | + | let i = match self.list_state.selected() { |
|
| 52 | + | Some(i) if i < self.snippets.len() - 1 => i + 1, |
|
| 53 | + | Some(_) => 0, |
|
| 54 | + | None => 0, |
|
| 55 | + | }; |
|
| 56 | + | self.list_state.select(Some(i)); |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | fn copy_selected(&self) { |
|
| 60 | + | if let Some(snippet) = self.selected_snippet() { |
|
| 61 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 62 | + | let _ = clipboard.set_text(&snippet.content); |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | fn main() -> Result<(), Box<dyn std::error::Error>> { |
|
| 69 | + | let db = db::init_db(); |
|
| 70 | + | let snippets = db::get_all_snippets(&db); |
|
| 71 | + | ||
| 72 | + | ratatui::run(|terminal| run_app(terminal, App::new(snippets))) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | fn run_app( |
|
| 76 | + | terminal: &mut DefaultTerminal, |
|
| 77 | + | mut app: App, |
|
| 78 | + | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 79 | + | while !app.should_quit { |
|
| 80 | + | terminal.draw(|frame| { |
|
| 81 | + | let chunks = Layout::horizontal([ |
|
| 82 | + | Constraint::Percentage(30), |
|
| 83 | + | Constraint::Percentage(70), |
|
| 84 | + | ]) |
|
| 85 | + | .split(frame.area()); |
|
| 86 | + | ||
| 87 | + | let items: Vec<ListItem> = app |
|
| 88 | + | .snippets |
|
| 89 | + | .iter() |
|
| 90 | + | .map(|s| ListItem::new(s.name.as_str())) |
|
| 91 | + | .collect(); |
|
| 92 | + | ||
| 93 | + | let list = List::new(items) |
|
| 94 | + | .block(Block::default().title(" Snippets ").borders(Borders::ALL)) |
|
| 95 | + | .highlight_style( |
|
| 96 | + | Style::default() |
|
| 97 | + | .fg(Color::Yellow) |
|
| 98 | + | .add_modifier(Modifier::BOLD), |
|
| 99 | + | ) |
|
| 100 | + | .highlight_symbol("▶ "); |
|
| 101 | + | ||
| 102 | + | frame.render_stateful_widget(list, chunks[0], &mut app.list_state); |
|
| 103 | + | ||
| 104 | + | let content = app |
|
| 105 | + | .selected_snippet() |
|
| 106 | + | .map(|s| s.content.as_str()) |
|
| 107 | + | .unwrap_or(""); |
|
| 108 | + | ||
| 109 | + | let paragraph = Paragraph::new(Text::raw(content)) |
|
| 110 | + | .block(Block::default().title(" Content ").borders(Borders::ALL)); |
|
| 111 | + | ||
| 112 | + | frame.render_widget(paragraph, chunks[1]); |
|
| 113 | + | })?; |
|
| 114 | + | ||
| 115 | + | if let Event::Key(key) = event::read()? { |
|
| 116 | + | match key.code { |
|
| 117 | + | KeyCode::Char('q') => app.should_quit = true, |
|
| 118 | + | KeyCode::Char('j') | KeyCode::Down => app.move_down(), |
|
| 119 | + | KeyCode::Char('k') | KeyCode::Up => app.move_up(), |
|
| 120 | + | KeyCode::Char('y') => app.copy_selected(), |
|
| 121 | + | _ => {} |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | Ok(()) |
|
| 127 | + | } |
| 69 | 69 | ) |
|
| 70 | 70 | .ok() |
|
| 71 | 71 | } |
|
| 72 | + | ||
| 73 | + | pub fn get_all_snippets(db: &Db) -> Vec<Snippet> { |
|
| 74 | + | let conn = db.lock().unwrap(); |
|
| 75 | + | let mut stmt = conn |
|
| 76 | + | .prepare("SELECT id, short_id, content, name FROM snippets ORDER BY id DESC") |
|
| 77 | + | .expect("Failed to prepare statement"); |
|
| 78 | + | stmt.query_map([], |row| { |
|
| 79 | + | Ok(Snippet { |
|
| 80 | + | id: row.get(0)?, |
|
| 81 | + | short_id: row.get(1)?, |
|
| 82 | + | content: row.get(2)?, |
|
| 83 | + | name: row.get(3)?, |
|
| 84 | + | }) |
|
| 85 | + | }) |
|
| 86 | + | .expect("Failed to query snippets") |
|
| 87 | + | .filter_map(|r| r.ok()) |
|
| 88 | + | .collect() |
|
| 89 | + | } |
| 1 | + | pub mod db; |
| 1 | - | mod db; |
|
| 2 | - | ||
| 3 | 1 | use askama::Template; |
|
| 4 | 2 | use askama_web::WebTemplate; |
|
| 5 | 3 | use axum::{ |
|
| 10 | 8 | routing::{get, post}, |
|
| 11 | 9 | }; |
|
| 12 | 10 | use serde::Deserialize; |
|
| 11 | + | use sipp_rust::db::{self, Db}; |
|
| 13 | 12 | use tower_http::services::ServeDir; |
|
| 14 | - | ||
| 15 | - | use db::Db; |
|
| 16 | 13 | ||
| 17 | 14 | #[derive(Template)] |
|
| 18 | 15 | #[template(path = "index.html")] |
|