diff --git a/README.md b/README.md index 48106eb..da3d8c9 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,9 @@ A cross-platform terminal database tool written in Rust ![gobang](./resources/gobang.gif) + +## Features + +- Cross-platform support (macOS, Windows, Linux) +- Multiple Database support (MySQL PostgreSQL, SQLite) +- Intuitive keyboard only control diff --git a/resources/gobang.gif b/resources/gobang.gif index 4d4e87b..0be5718 100644 Binary files a/resources/gobang.gif and b/resources/gobang.gif differ diff --git a/src/components/table.rs b/src/components/table.rs index 12f4116..ad7be03 100644 --- a/src/components/table.rs +++ b/src/components/table.rs @@ -6,7 +6,7 @@ use tui::{ backend::Backend, layout::{Constraint, Rect}, style::{Color, Style}, - widgets::{Block, Borders, Cell, Row, Table as WTable, TableState}, + widgets::{Block, Borders, Cell, Row, Table, TableState}, Frame, }; @@ -15,7 +15,9 @@ pub struct TableComponent { pub headers: Vec, pub rows: Vec>, pub column_index: usize, + pub column_page: usize, pub scroll: VerticalScroll, + pub select_entire_row: bool, } impl Default for TableComponent { @@ -24,13 +26,26 @@ impl Default for TableComponent { state: TableState::default(), headers: vec![], rows: vec![], + column_page: 0, column_index: 0, scroll: VerticalScroll::new(), + select_entire_row: false, } } } impl TableComponent { + pub fn reset(&mut self, headers: Vec, rows: Vec>) { + self.headers = headers; + self.rows = rows; + self.column_page = 0; + self.column_index = 1; + self.state.select(None); + if !self.rows.is_empty() { + self.state.select(Some(0)); + } + } + pub fn next(&mut self, lines: usize) { let i = match self.state.selected() { Some(i) => { @@ -42,17 +57,23 @@ impl TableComponent { } None => None, }; + self.select_entire_row = false; self.state.select(i); } - pub fn reset(&mut self, headers: Vec, rows: Vec>) { - self.headers = headers; - self.rows = rows; - self.column_index = 0; - self.state.select(None); - if !self.rows.is_empty() { - self.state.select(Some(0)); - } + pub fn previous(&mut self, lines: usize) { + let i = match self.state.selected() { + Some(i) => { + if i <= lines { + Some(0) + } else { + Some(i - lines) + } + } + None => None, + }; + self.select_entire_row = false; + self.state.select(i); } pub fn scroll_top(&mut self) { @@ -70,34 +91,47 @@ impl TableComponent { self.state.select(Some(self.rows.len() - 1)); } - pub fn previous(&mut self, lines: usize) { - let i = match self.state.selected() { - Some(i) => { - if i <= lines { - Some(0) - } else { - Some(i - lines) - } - } - None => None, - }; - self.state.select(i); - } - pub fn next_column(&mut self) { - if self.headers.len() > 9 && self.column_index < self.headers.len() - 9 { - self.column_index += 1 + if self.rows.is_empty() { + return; } + if self.column_index == self.headers.len() - 1 { + return; + } + if self.column_index == 9 { + self.next_column_page(); + return; + } + self.select_entire_row = false; + self.column_index += 1; } pub fn previous_column(&mut self) { - if self.column_index > 0 { - self.column_index -= 1 + if self.rows.is_empty() { + return; + } + if self.column_index == 1 { + self.previous_column_page(); + return; + } + self.select_entire_row = false; + self.column_index -= 1; + } + + pub fn next_column_page(&mut self) { + if self.headers.len() > 9 && self.column_page < self.headers.len() - 9 { + self.column_page += 1 + } + } + + pub fn previous_column_page(&mut self) { + if self.column_page > 0 { + self.column_page -= 1 } } pub fn headers(&self) -> Vec { - let mut headers = self.headers[self.column_index..].to_vec(); + let mut headers = self.headers[self.column_page..].to_vec(); headers.insert(0, "".to_string()); headers } @@ -106,7 +140,7 @@ impl TableComponent { let rows = self .rows .iter() - .map(|row| row[self.column_index..].to_vec()) + .map(|row| row[self.column_page..].to_vec()) .collect::>>(); let mut new_rows = match self.state.selected() { Some(index) => { @@ -146,25 +180,42 @@ impl DrawableComponent for TableComponent { .map(|h| Cell::from(h.to_string()).style(Style::default())); let header = Row::new(header_cells).height(1).bottom_margin(1); let rows = self.rows(); - let rows = rows.iter().map(|item| { + let rows = rows.iter().enumerate().map(|(row_index, item)| { let height = item .iter() .map(|content| content.chars().filter(|c| *c == '\n').count()) .max() .unwrap_or(0) + 1; - let cells = item - .iter() - .map(|c| Cell::from(c.to_string()).style(Style::default())); + let cells = item.iter().enumerate().map(|(column_page, c)| { + Cell::from(c.to_string()).style(if column_page == self.column_index { + match self.state.selected() { + Some(selected_row) => { + if row_index == selected_row { + Style::default().bg(Color::Blue) + } else { + Style::default() + } + } + None => Style::default(), + } + } else { + Style::default() + }) + }); Row::new(cells).height(height as u16).bottom_margin(1) }); let widths = (0..10) .map(|_| Constraint::Percentage(10)) .collect::>(); - let t = WTable::new(rows) + let t = Table::new(rows) .header(header) .block(Block::default().borders(Borders::ALL).title("Records")) - .highlight_style(Style::default().bg(Color::Blue)) + .highlight_style(if self.select_entire_row { + Style::default().bg(Color::Blue) + } else { + Style::default() + }) .style(if focused { Style::default() } else { @@ -187,6 +238,7 @@ impl Component for TableComponent { Key::Char('k') => self.previous(1), Key::Ctrl('u') => self.previous(10), Key::Char('g') => self.scroll_top(), + Key::Char('r') => self.select_entire_row = true, Key::Shift('G') | Key::Shift('g') => self.scroll_bottom(), Key::Char('l') => self.next_column(), _ => (), @@ -194,3 +246,28 @@ impl Component for TableComponent { Ok(()) } } + +#[cfg(test)] +mod test { + use super::TableComponent; + + #[test] + fn test_headers() { + let mut component = TableComponent::default(); + component.headers = vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(); + assert_eq!(component.headers(), vec!["", "a", "b", "c"]) + } + + #[test] + fn test_rows() { + let mut component = TableComponent::default(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + assert_eq!( + component.rows(), + vec![vec!["1", "a", "b", "c"], vec!["2", "d", "e", "f"]], + ) + } +} diff --git a/src/main.rs b/src/main.rs index c50f29f..e93cfc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,12 +12,15 @@ mod log; use crate::app::App; use crate::event::{Event, Key}; use crate::handlers::handle_app; +use anyhow::Result; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use std::{ + io::{self, stdout}, + panic, }; -use std::io::stdout; use tui::{backend::CrosstermBackend, Terminal}; #[tokio::main] @@ -28,8 +31,9 @@ async fn main() -> anyhow::Result<()> { let user_config = user_config::UserConfig::new("sample.toml").ok(); - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let stdout = stdout(); + setup_terminal()?; + set_panic_handlers()?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -54,13 +58,36 @@ async fn main() -> anyhow::Result<()> { } } - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; + shutdown_terminal(); terminal.show_cursor()?; Ok(()) } + +fn setup_terminal() -> Result<()> { + enable_raw_mode()?; + io::stdout().execute(EnterAlternateScreen)?; + Ok(()) +} + +fn set_panic_handlers() -> Result<()> { + panic::set_hook(Box::new(|e| { + eprintln!("panic: {:?}", e); + shutdown_terminal(); + })); + Ok(()) +} + +fn shutdown_terminal() { + let leave_screen = io::stdout().execute(LeaveAlternateScreen).map(|_f| ()); + + if let Err(e) = leave_screen { + eprintln!("leave_screen failed:\n{}", e); + } + + let leave_raw_mode = disable_raw_mode(); + + if let Err(e) = leave_raw_mode { + eprintln!("leave_raw_mode failed:\n{}", e); + } +}