diff --git a/Cargo.lock b/Cargo.lock index 7183651..82a9e89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "database-tree" +version = "0.1.2" +dependencies = [ + "anyhow", + "chrono", + "sqlx", + "thiserror", +] + [[package]] name = "digest" version = "0.9.0" @@ -294,6 +304,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "easy-cast" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd102ee8c418348759919b83b81cdbdc933ffe29740b903df448b4bafaa348e" + [[package]] name = "either" version = "1.6.1" @@ -471,6 +487,8 @@ dependencies = [ "anyhow", "chrono", "crossterm 0.19.0", + "database-tree", + "easy-cast", "futures", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 301cb86..5c17f20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,10 @@ toml = "0.4" regex = "1" strum = "0.21" strum_macros = "0.21" +database-tree = { path = "./database-tree", version = "0.1" } +easy-cast = "0.4" + +[workspace] +members=[ + "database-tree" +] diff --git a/database-tree/Cargo.toml b/database-tree/Cargo.toml new file mode 100644 index 0000000..0e0805f --- /dev/null +++ b/database-tree/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "database-tree" +version = "0.1.2" +authors = ["Stephan Dilly "] +edition = "2018" +license = "MIT" +homepage = "https://github.com/TaKO8Ki/gobang" +repository = "https://github.com/TaKO8Ki/gobang" +readme = "README.md" +description = "A cross-platform terminal database tool written in Rust" + +[dependencies] +thiserror = "1.0" +sqlx = { version = "0.4.1", features = ["mysql", "chrono", "runtime-tokio-rustls"] } +chrono = "0.4" +anyhow = "1.0.38" diff --git a/database-tree/src/databasetree.rs b/database-tree/src/databasetree.rs new file mode 100644 index 0000000..7d126c6 --- /dev/null +++ b/database-tree/src/databasetree.rs @@ -0,0 +1,284 @@ +use crate::Table; +use crate::{ + databasetreeitems::DatabaseTreeItems, error::Result, item::DatabaseTreeItemKind, + tree_iter::TreeIterator, +}; +use std::{collections::BTreeSet, usize}; + +/// +#[derive(Copy, Clone, Debug)] +pub enum MoveSelection { + Up, + Down, + Left, + Right, + Top, + End, + PageDown, + PageUp, +} + +#[derive(Debug, Clone, Copy)] +pub struct VisualSelection { + pub count: usize, + pub index: usize, +} + +/// wraps `DatabaseTreeItems` as a datastore and adds selection functionality +#[derive(Default)] +pub struct DatabaseTree { + items: DatabaseTreeItems, + pub selection: Option, + visual_selection: Option, +} + +impl DatabaseTree { + pub fn new(list: &[crate::Database], collapsed: &BTreeSet<&String>) -> Result { + let mut new_self = Self { + items: DatabaseTreeItems::new(list, collapsed)?, + selection: if list.is_empty() { None } else { Some(0) }, + visual_selection: None, + }; + new_self.visual_selection = new_self.calc_visual_selection(); + + Ok(new_self) + } + + pub fn collapse_but_root(&mut self) { + self.items.collapse(0, true); + self.items.expand(0, false); + } + + /// iterates visible elements starting from `start_index_visual` + pub fn iterate(&self, start_index_visual: usize, max_amount: usize) -> TreeIterator<'_> { + let start = self + .visual_index_to_absolute(start_index_visual) + .unwrap_or_default(); + TreeIterator::new(self.items.iterate(start, max_amount), self.selection) + } + + pub const fn visual_selection(&self) -> Option<&VisualSelection> { + self.visual_selection.as_ref() + } + + pub fn selected_item(&self) -> Option<&crate::DatabaseTreeItem> { + self.selection + .and_then(|index| self.items.tree_items.get(index)) + } + + pub fn selected_table(&self) -> Option<(Table, String)> { + self.selection.and_then(|index| { + let item = &self.items.tree_items[index]; + match item.kind() { + DatabaseTreeItemKind::Database { .. } => None, + DatabaseTreeItemKind::Table { table, database } => { + Some((table.clone(), database.clone())) + } + } + }) + } + + pub fn collapse_recursive(&mut self) { + if let Some(selection) = self.selection { + self.items.collapse(selection, true); + } + } + + pub fn expand_recursive(&mut self) { + if let Some(selection) = self.selection { + self.items.expand(selection, true); + } + } + + pub fn move_selection(&mut self, dir: MoveSelection) -> bool { + self.selection.map_or(false, |selection| { + let new_index = match dir { + MoveSelection::Up => self.selection_updown(selection, true), + MoveSelection::Down => self.selection_updown(selection, false), + MoveSelection::Left => self.selection_left(selection), + MoveSelection::Right => self.selection_right(selection), + MoveSelection::Top => Self::selection_start(selection), + MoveSelection::End => self.selection_end(selection), + MoveSelection::PageDown | MoveSelection::PageUp => None, + }; + + let changed_index = new_index.map(|i| i != selection).unwrap_or_default(); + + if changed_index { + self.selection = new_index; + self.visual_selection = self.calc_visual_selection(); + } + + changed_index || new_index.is_some() + }) + } + + fn visual_index_to_absolute(&self, visual_index: usize) -> Option { + self.items + .iterate(0, self.items.len()) + .enumerate() + .find_map( + |(i, (abs, _))| { + if i == visual_index { + Some(abs) + } else { + None + } + }, + ) + } + + fn calc_visual_selection(&self) -> Option { + self.selection.map(|selection_absolute| { + let mut count = 0; + let mut visual_index = 0; + for (index, _item) in self.items.iterate(0, self.items.len()) { + if selection_absolute == index { + visual_index = count; + } + + count += 1; + } + + VisualSelection { + index: visual_index, + count, + } + }) + } + + const fn selection_start(current_index: usize) -> Option { + if current_index == 0 { + None + } else { + Some(0) + } + } + + fn selection_end(&self, current_index: usize) -> Option { + let items_max = self.items.len().saturating_sub(1); + + let mut new_index = items_max; + + loop { + if self.is_visible_index(new_index) { + break; + } + + if new_index == 0 { + break; + } + + new_index = new_index.saturating_sub(1); + new_index = std::cmp::min(new_index, items_max); + } + + if new_index == current_index { + None + } else { + Some(new_index) + } + } + + fn selection_updown(&self, current_index: usize, up: bool) -> Option { + let mut index = current_index; + + loop { + index = { + let new_index = if up { + index.saturating_sub(1) + } else { + index.saturating_add(1) + }; + + if new_index == index { + break; + } + + if new_index >= self.items.len() { + break; + } + + let item = self + .items + .tree_items + .iter() + .filter(|item| item.info().is_visible()) + .last() + .unwrap(); + + if !up + && self.selected_item().unwrap().kind().is_database() + && self.selected_item().unwrap() == item + { + break; + } + + new_index + }; + + if self.is_visible_index(index) { + break; + } + } + + if index == current_index { + None + } else { + Some(index) + } + } + + fn select_parent(&mut self, current_index: usize) -> Option { + let indent = self.items.tree_items.get(current_index)?.info().indent(); + + let mut index = current_index; + + while let Some(selection) = self.selection_updown(index, true) { + index = selection; + + if self.items.tree_items[index].info().indent() < indent { + break; + } + } + + if index == current_index { + None + } else { + Some(index) + } + } + + fn selection_left(&mut self, current_index: usize) -> Option { + let item = &mut self.items.tree_items.get(current_index)?; + + if item.kind().is_database() && !item.kind().is_database_collapsed() { + self.items.collapse(current_index, false); + return Some(current_index); + } + + self.select_parent(current_index) + } + + fn selection_right(&mut self, current_selection: usize) -> Option { + let item = &mut self.items.tree_items.get(current_selection)?; + + if item.kind().is_database() { + if item.kind().is_database_collapsed() { + self.items.expand(current_selection, false); + return Some(current_selection); + } + return self.selection_updown(current_selection, false); + } + + None + } + + fn is_visible_index(&self, index: usize) -> bool { + self.items + .tree_items + .get(index) + .map(|item| item.info().is_visible()) + .unwrap_or_default() + } +} diff --git a/database-tree/src/databasetreeitems.rs b/database-tree/src/databasetreeitems.rs new file mode 100644 index 0000000..69db1dd --- /dev/null +++ b/database-tree/src/databasetreeitems.rs @@ -0,0 +1,161 @@ +use crate::Database; +use crate::{error::Result, treeitems_iter::TreeItemsIterator}; +use crate::{item::DatabaseTreeItemKind, DatabaseTreeItem}; +use std::{ + collections::{BTreeSet, HashMap}, + usize, +}; + +#[derive(Default)] +pub struct DatabaseTreeItems { + pub tree_items: Vec, +} + +impl DatabaseTreeItems { + /// + pub fn new(list: &[Database], collapsed: &BTreeSet<&String>) -> Result { + Ok(Self { + tree_items: Self::create_items(list, collapsed)?, + }) + } + + fn create_items( + list: &[Database], + collapsed: &BTreeSet<&String>, + ) -> Result> { + let mut items = Vec::with_capacity(list.len()); + let mut items_added: HashMap = HashMap::with_capacity(list.len()); + + for e in list { + { + Self::push_databases(e, &mut items, &mut items_added, collapsed)?; + } + for table in &e.tables { + items.push(DatabaseTreeItem::new_table(e, table)?); + } + } + + Ok(items) + } + + /// how many individual items are in the list + pub fn len(&self) -> usize { + self.tree_items.len() + } + + /// iterates visible elements + pub const fn iterate(&self, start: usize, max_amount: usize) -> TreeItemsIterator<'_> { + TreeItemsIterator::new(self, start, max_amount) + } + + fn push_databases<'a>( + database: &'a Database, + nodes: &mut Vec, + items_added: &mut HashMap, + collapsed: &BTreeSet<&String>, + ) -> Result<()> { + let c = database.name.clone(); + if !items_added.contains_key(&c) { + // add node and set count to have no children + items_added.insert(c.clone(), 0); + + // increase the number of children in the parent node count + *items_added.entry(database.name.clone()).or_insert(0) += 1; + + let is_collapsed = collapsed.contains(&c); + nodes.push(DatabaseTreeItem::new_database(database, is_collapsed)?); + } + + // increase child count in parent node (the above ancenstor ignores the leaf component) + *items_added.entry(database.name.clone()).or_insert(0) += 1; + + Ok(()) + } + + pub fn collapse(&mut self, index: usize, recursive: bool) { + if self.tree_items[index].kind().is_database() { + self.tree_items[index].collapse_database(); + + let name = self.tree_items[index].kind().name(); + + for i in index + 1..self.tree_items.len() { + let item = &mut self.tree_items[i]; + + if recursive && item.kind().is_database() { + item.collapse_database(); + } + + if let Some(db) = item.kind().database_name() { + if db == name { + item.hide(); + } + } else { + return; + } + } + } + } + + pub fn expand(&mut self, index: usize, recursive: bool) { + if self.tree_items[index].kind().is_database() { + self.tree_items[index].expand_database(); + + let name = self.tree_items[index].kind().name(); + + if recursive { + for i in index + 1..self.tree_items.len() { + let item = &mut self.tree_items[i]; + + if let Some(db) = item.kind().database_name() { + if *db != name { + break; + } + } + + if item.kind().is_database() && item.kind().is_database_collapsed() { + item.expand_database(); + } + } + } + + self.update_visibility(&Some(name), index + 1, false); + } + } + + fn update_visibility(&mut self, prefix: &Option, start_idx: usize, set_defaults: bool) { + let mut inner_collapsed: Option = None; + + for i in start_idx..self.tree_items.len() { + if let Some(ref collapsed_item) = inner_collapsed { + if let Some(db) = self.tree_items[i].kind().database_name().clone() { + if db == *collapsed_item { + if set_defaults { + self.tree_items[i].info_mut().set_visible(false); + } + continue; + } + } + inner_collapsed = None; + } + + let item_kind = self.tree_items[i].kind().clone(); + + if matches!(item_kind, DatabaseTreeItemKind::Database{ collapsed, .. } if collapsed) { + inner_collapsed = item_kind.database_name().clone(); + } + + if let Some(db) = item_kind.database_name() { + if prefix.as_ref().map_or(true, |prefix| *prefix == *db) { + self.tree_items[i].info_mut().set_visible(true); + } + } else { + // if we do not set defaults we can early out + if set_defaults { + self.tree_items[i].info_mut().set_visible(false); + } else { + return; + } + } + } + } +} diff --git a/database-tree/src/error.rs b/database-tree/src/error.rs new file mode 100644 index 0000000..8d5a0a4 --- /dev/null +++ b/database-tree/src/error.rs @@ -0,0 +1,10 @@ +use std::num::TryFromIntError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("TryFromInt error:{0}")] + IntConversion(#[from] TryFromIntError), +} + +pub type Result = std::result::Result; diff --git a/database-tree/src/item.rs b/database-tree/src/item.rs new file mode 100644 index 0000000..d771775 --- /dev/null +++ b/database-tree/src/item.rs @@ -0,0 +1,163 @@ +use crate::error::Result; +use crate::{Database, Table}; +use std::convert::TryFrom; + +#[derive(Debug, Clone)] +pub struct TreeItemInfo { + indent: u8, + visible: bool, +} + +impl TreeItemInfo { + pub const fn new(indent: u8) -> Self { + Self { + indent, + visible: true, + } + } + + pub const fn is_visible(&self) -> bool { + self.visible + } + + pub const fn indent(&self) -> u8 { + self.indent + } + + pub fn unindent(&mut self) { + self.indent = self.indent.saturating_sub(1); + } + + pub fn set_visible(&mut self, visible: bool) { + self.visible = visible; + } +} + +/// `DatabaseTreeItem` can be of two kinds +#[derive(PartialEq, Debug, Clone)] +pub enum DatabaseTreeItemKind { + Database { name: String, collapsed: bool }, + Table { database: String, table: Table }, +} + +impl DatabaseTreeItemKind { + pub const fn is_database(&self) -> bool { + matches!(self, Self::Database { .. }) + } + + pub const fn is_table(&self) -> bool { + matches!(self, Self::Table { .. }) + } + + pub const fn is_database_collapsed(&self) -> bool { + match self { + Self::Database { collapsed, .. } => *collapsed, + Self::Table { .. } => false, + } + } + + pub fn name(&self) -> String { + match self { + Self::Database { name, .. } => name.to_string(), + Self::Table { table, .. } => table.name.clone(), + } + } + + pub fn database_name(&self) -> Option { + match self { + Self::Database { .. } => None, + Self::Table { database, .. } => Some(database.clone()), + } + } +} + +/// `DatabaseTreeItem` can be of two kinds: see `DatabaseTreeItem` but shares an info +#[derive(Debug, Clone)] +pub struct DatabaseTreeItem { + info: TreeItemInfo, + kind: DatabaseTreeItemKind, +} + +impl DatabaseTreeItem { + pub fn new_table(database: &Database, table: &Table) -> Result { + let indent = u8::try_from((3_usize).saturating_sub(2))?; + + Ok(Self { + info: TreeItemInfo::new(indent), + kind: DatabaseTreeItemKind::Table { + database: database.name.clone(), + table: table.clone(), + }, + }) + } + + pub fn new_database(database: &Database, collapsed: bool) -> Result { + Ok(Self { + info: TreeItemInfo::new(0), + kind: DatabaseTreeItemKind::Database { + name: database.name.to_string(), + collapsed, + }, + }) + } + + pub const fn info(&self) -> &TreeItemInfo { + &self.info + } + + pub fn info_mut(&mut self) -> &mut TreeItemInfo { + &mut self.info + } + + pub const fn kind(&self) -> &DatabaseTreeItemKind { + &self.kind + } + + pub fn collapse_database(&mut self) { + if let DatabaseTreeItemKind::Database { name, .. } = &self.kind { + self.kind = DatabaseTreeItemKind::Database { + name: name.to_string(), + collapsed: true, + } + } + } + + pub fn expand_database(&mut self) { + if let DatabaseTreeItemKind::Database { name, .. } = &self.kind { + self.kind = DatabaseTreeItemKind::Database { + name: name.to_string(), + collapsed: false, + }; + } + } + + pub fn hide(&mut self) { + self.info.visible = false; + } +} + +impl Eq for DatabaseTreeItem {} + +impl PartialEq for DatabaseTreeItem { + fn eq(&self, other: &Self) -> bool { + if self.kind.is_database() && other.kind().is_database() { + return self.kind.name().eq(&other.kind.name()); + } + if !self.kind.is_database() && !other.kind.is_database() { + return self.kind.name().eq(&other.kind.name()); + } + false + } +} + +impl PartialOrd for DatabaseTreeItem { + fn partial_cmp(&self, other: &Self) -> Option { + self.kind.name().partial_cmp(&other.kind.name()) + } +} + +impl Ord for DatabaseTreeItem { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.kind.name().cmp(&other.kind.name()) + } +} diff --git a/database-tree/src/lib.rs b/database-tree/src/lib.rs new file mode 100644 index 0000000..a4c3ebf --- /dev/null +++ b/database-tree/src/lib.rs @@ -0,0 +1,39 @@ +mod databasetree; +mod databasetreeitems; +mod error; +mod item; +mod tree_iter; +mod treeitems_iter; + +pub use crate::{ + databasetree::DatabaseTree, + databasetree::MoveSelection, + item::{DatabaseTreeItem, TreeItemInfo}, +}; + +#[derive(Clone)] +pub struct Database { + pub name: String, + pub tables: Vec, +} + +impl Database { + pub fn new(database: String, tables: Vec
) -> Self { + Self { + name: database, + tables, + } + } +} + +#[derive(sqlx::FromRow, Debug, Clone, PartialEq)] +pub struct Table { + #[sqlx(rename = "Name")] + pub name: String, + #[sqlx(rename = "Create_time")] + pub create_time: chrono::DateTime, + #[sqlx(rename = "Update_time")] + pub update_time: Option>, + #[sqlx(rename = "Engine")] + pub engine: Option, +} diff --git a/database-tree/src/tree_iter.rs b/database-tree/src/tree_iter.rs new file mode 100644 index 0000000..7a0eabf --- /dev/null +++ b/database-tree/src/tree_iter.rs @@ -0,0 +1,25 @@ +use crate::{item::DatabaseTreeItem, treeitems_iter::TreeItemsIterator}; + +pub struct TreeIterator<'a> { + item_iter: TreeItemsIterator<'a>, + selection: Option, +} + +impl<'a> TreeIterator<'a> { + pub const fn new(item_iter: TreeItemsIterator<'a>, selection: Option) -> Self { + Self { + item_iter, + selection, + } + } +} + +impl<'a> Iterator for TreeIterator<'a> { + type Item = (&'a DatabaseTreeItem, bool); + + fn next(&mut self) -> Option { + self.item_iter + .next() + .map(|(index, item)| (item, self.selection.map(|i| i == index).unwrap_or_default())) + } +} diff --git a/database-tree/src/treeitems_iter.rs b/database-tree/src/treeitems_iter.rs new file mode 100644 index 0000000..489f515 --- /dev/null +++ b/database-tree/src/treeitems_iter.rs @@ -0,0 +1,56 @@ +use crate::{databasetreeitems::DatabaseTreeItems, item::DatabaseTreeItem}; + +pub struct TreeItemsIterator<'a> { + tree: &'a DatabaseTreeItems, + index: usize, + increments: Option, + max_amount: usize, +} + +impl<'a> TreeItemsIterator<'a> { + pub const fn new(tree: &'a DatabaseTreeItems, start: usize, max_amount: usize) -> Self { + TreeItemsIterator { + max_amount, + increments: None, + index: start, + tree, + } + } +} + +impl<'a> Iterator for TreeItemsIterator<'a> { + type Item = (usize, &'a DatabaseTreeItem); + + fn next(&mut self) -> Option { + if self.increments.unwrap_or_default() < self.max_amount { + let items = &self.tree.tree_items; + + let mut init = self.increments.is_none(); + + if let Some(i) = self.increments.as_mut() { + *i += 1; + } else { + self.increments = Some(0); + }; + + loop { + if !init { + self.index += 1; + } + init = false; + + if self.index >= self.tree.len() { + break; + } + + let elem = &items[self.index]; + + if elem.info().is_visible() { + return Some((self.index, &items[self.index])); + } + } + } + + None + } +} diff --git a/resources/gobang.gif b/resources/gobang.gif index d602581..b5042c0 100644 Binary files a/resources/gobang.gif and b/resources/gobang.gif differ diff --git a/src/app.rs b/src/app.rs index be944bc..7c3848a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,18 @@ +use crate::components::utils::scroll_vertical::VerticalScroll; use crate::{ + components::DatabasesComponent, user_config::{Connection, UserConfig}, - utils::get_tables, }; use sqlx::mysql::MySqlPool; use strum::IntoEnumIterator; use strum_macros::EnumIter; -use tui::widgets::{ListState, TableState}; +use tui::{ + backend::Backend, + layout::{Constraint, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Cell, ListState, Row, Table as WTable, TableState}, + Frame, +}; use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Copy, EnumIter)] @@ -22,34 +29,17 @@ impl std::fmt::Display for Tab { impl Tab { pub fn names() -> Vec { - Self::iter().map(|tab| tab.to_string()).collect() + Self::iter() + .map(|tab| format!("{} [{}]", tab, tab as u8 + 1)) + .collect() } } pub enum FocusBlock { - DabataseList(bool), - TableList(bool), - RecordTable(bool), + DabataseList, + RecordTable, ConnectionList, - Query(bool), -} - -#[derive(Clone)] -pub struct Database { - pub name: String, - pub tables: Vec
, -} - -#[derive(sqlx::FromRow, Debug, Clone)] -pub struct Table { - #[sqlx(rename = "Name")] - pub name: String, - #[sqlx(rename = "Create_time")] - pub create_time: chrono::DateTime, - #[sqlx(rename = "Update_time")] - pub update_time: Option>, - #[sqlx(rename = "Engine")] - pub engine: Option, + Query, } #[derive(sqlx::FromRow, Debug, Clone)] @@ -69,6 +59,7 @@ pub struct RecordTable { pub headers: Vec, pub rows: Vec>, pub column_index: usize, + pub scroll: VerticalScroll, } impl Default for RecordTable { @@ -78,6 +69,7 @@ impl Default for RecordTable { headers: vec![], rows: vec![], column_index: 0, + scroll: VerticalScroll::new(), } } } @@ -87,28 +79,28 @@ impl RecordTable { let i = match self.state.selected() { Some(i) => { if i >= self.rows.len() - 1 { - 0 + Some(i) } else { - i + 1 + Some(i + 1) } } - None => 0, + None => None, }; - self.state.select(Some(i)); + self.state.select(i); } pub fn previous(&mut self) { let i = match self.state.selected() { Some(i) => { if i == 0 { - self.rows.len() - 1 + Some(i) } else { - i - 1 + Some(i - 1) } } - None => 0, + None => None, }; - self.state.select(Some(i)); + self.state.select(i); } pub fn next_column(&mut self) { @@ -140,14 +132,61 @@ impl RecordTable { } rows } -} -impl Database { - pub async fn new(name: String, pool: &MySqlPool) -> anyhow::Result { - Ok(Self { - name: name.clone(), - tables: get_tables(name, pool).await?, - }) + pub fn draw( + &mut self, + f: &mut Frame<'_, B>, + layout_chunk: Rect, + focused: bool, + ) -> anyhow::Result<()> { + self.state.selected().map_or_else( + || { + self.scroll.reset(); + }, + |selection| { + self.scroll.update( + selection, + self.rows.len(), + layout_chunk.height.saturating_sub(2) as usize, + ); + }, + ); + + let headers = self.headers(); + let header_cells = headers + .iter() + .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 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())); + Row::new(cells).height(height as u16).bottom_margin(1) + }); + let widths = (0..10) + .map(|_| Constraint::Percentage(10)) + .collect::>(); + let t = WTable::new(rows) + .header(header) + .block(Block::default().borders(Borders::ALL).title("Records")) + .highlight_style(Style::default().fg(Color::Green)) + .style(if focused { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }) + .widths(&widths); + f.render_stateful_widget(t, layout_chunk, &mut self.state); + + self.scroll.draw(f, layout_chunk); + Ok(()) } } @@ -155,7 +194,6 @@ pub struct App { pub input: String, pub input_cursor_x: u16, pub query: String, - pub databases: Vec, pub record_table: RecordTable, pub structure_table: RecordTable, pub focus_block: FocusBlock, @@ -164,6 +202,7 @@ pub struct App { pub selected_connection: ListState, pub selected_database: ListState, pub selected_table: ListState, + pub databases: DatabasesComponent, pub pool: Option, pub error: Option, } @@ -174,15 +213,15 @@ impl Default for App { input: String::new(), input_cursor_x: 0, query: String::new(), - databases: Vec::new(), record_table: RecordTable::default(), structure_table: RecordTable::default(), - focus_block: FocusBlock::DabataseList(false), + focus_block: FocusBlock::DabataseList, selected_tab: Tab::Records, user_config: None, selected_connection: ListState::default(), selected_database: ListState::default(), selected_table: ListState::default(), + databases: DatabasesComponent::new(), pool: None, error: None, } @@ -190,78 +229,6 @@ impl Default for App { } impl App { - pub fn next_tab(&mut self) { - self.selected_tab = match self.selected_tab { - Tab::Records => Tab::Structure, - Tab::Structure => Tab::Records, - } - } - - pub fn previous_tab(&mut self) { - self.selected_tab = match self.selected_tab { - Tab::Records => Tab::Structure, - Tab::Structure => Tab::Records, - } - } - - pub fn next_table(&mut self) { - let i = match self.selected_table.selected() { - Some(i) => { - if i >= self.selected_database().unwrap().tables.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.selected_table.select(Some(i)); - } - - pub fn previous_table(&mut self) { - let i = match self.selected_table.selected() { - Some(i) => { - if i == 0 { - self.selected_database().unwrap().tables.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.selected_table.select(Some(i)); - } - - pub fn next_database(&mut self) { - let i = match self.selected_database.selected() { - Some(i) => { - if i >= self.databases.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.selected_table.select(Some(0)); - self.selected_database.select(Some(i)); - } - - pub fn previous_database(&mut self) { - let i = match self.selected_database.selected() { - Some(i) => { - if i == 0 { - self.databases.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.selected_table.select(Some(0)); - self.selected_database.select(Some(i)); - } - pub fn next_connection(&mut self) { if let Some(config) = &self.user_config { let i = match self.selected_connection.selected() { @@ -306,23 +273,6 @@ impl App { } } - pub fn selected_database(&self) -> Option<&Database> { - match self.selected_database.selected() { - Some(i) => self.databases.get(i), - None => None, - } - } - - pub fn selected_table(&self) -> Option<&Table> { - match self.selected_table.selected() { - Some(i) => match self.selected_database() { - Some(db) => db.tables.get(i), - None => None, - }, - None => None, - } - } - pub fn selected_connection(&self) -> Option<&Connection> { match &self.user_config { Some(config) => match self.selected_connection.selected() { @@ -334,7 +284,7 @@ impl App { } pub fn table_status(&self) -> Vec { - if let Some(table) = self.selected_table() { + if let Some((table, _)) = self.databases.tree.selected_table() { return vec![ format!("created: {}", table.create_time.to_string()), format!( diff --git a/src/components/command.rs b/src/components/command.rs new file mode 100644 index 0000000..055ae19 --- /dev/null +++ b/src/components/command.rs @@ -0,0 +1,27 @@ +/// +#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)] +pub struct CommandText { + /// + pub name: String, + /// + pub desc: &'static str, + /// + pub group: &'static str, + /// + pub hide_help: bool, +} + +/// +pub struct CommandInfo { + /// + pub text: CommandText, + /// available but not active in the context + pub enabled: bool, + /// will show up in the quick bar + pub quick_bar: bool, + + /// available in current app state + pub available: bool, + /// used to order commands in quickbar + pub order: i8, +} diff --git a/src/components/databases.rs b/src/components/databases.rs new file mode 100644 index 0000000..4303435 --- /dev/null +++ b/src/components/databases.rs @@ -0,0 +1,148 @@ +use super::{ + utils::scroll_vertical::VerticalScroll, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, +}; +use crate::event::Key; +use crate::ui::common_nav; +use crate::ui::scrolllist::draw_list_block; +use anyhow::Result; +use database_tree::{DatabaseTree, DatabaseTreeItem}; +use std::convert::From; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::Span, + widgets::{Block, Borders}, + Frame, +}; + +// ▸ +const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}"; +// ▾ +const FOLDER_ICON_EXPANDED: &str = "\u{25be}"; +const EMPTY_STR: &str = ""; + +pub struct DatabasesComponent { + pub tree: DatabaseTree, + pub scroll: VerticalScroll, + pub focused: bool, +} + +impl DatabasesComponent { + pub fn new() -> Self { + Self { + tree: DatabaseTree::default(), + scroll: VerticalScroll::new(), + focused: true, + } + } + + fn tree_item_to_span(item: &DatabaseTreeItem, selected: bool, width: u16) -> Span<'_> { + let name = item.kind().name(); + let indent = item.info().indent(); + + let indent_str = if indent == 0 { + String::from("") + } else { + format!("{:w$}", " ", w = (indent as usize) * 2) + }; + + let is_database = item.kind().is_database(); + let path_arrow = if is_database { + if item.kind().is_database_collapsed() { + FOLDER_ICON_COLLAPSED + } else { + FOLDER_ICON_EXPANDED + } + } else { + EMPTY_STR + }; + + let name = format!( + "{}{}{:w$}", + indent_str, + path_arrow, + name, + w = width as usize + ); + Span::styled( + name, + if selected { + Style::default().fg(Color::Magenta).bg(Color::Green) + } else { + Style::default() + }, + ) + } + + fn draw_tree(&self, f: &mut Frame, area: Rect) { + let tree_height = usize::from(area.height.saturating_sub(2)); + self.tree.visual_selection().map_or_else( + || { + self.scroll.reset(); + }, + |selection| { + self.scroll + .update(selection.index, selection.count, tree_height); + }, + ); + + let items = self + .tree + .iterate(self.scroll.get_top(), tree_height) + .map(|(item, selected)| Self::tree_item_to_span(item, selected, area.width)); + + let title = "Databases"; + draw_list_block( + f, + area, + Block::default() + .title(Span::styled(title, Style::default())) + .style(if self.focused { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }) + .borders(Borders::ALL) + .border_style(Style::default()), + items, + ); + self.scroll.draw(f, area); + } +} + +impl DrawableComponent for DatabasesComponent { + fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> { + if true { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(100)].as_ref()) + .split(area); + + self.draw_tree(f, chunks[0]); + } + Ok(()) + } +} + +impl Component for DatabasesComponent { + fn commands(&self, _out: &mut Vec, _force_all: bool) -> CommandBlocking { + CommandBlocking::PassingOn + } + + fn event(&mut self, key: Key) -> Result { + if tree_nav(&mut self.tree, key) { + return Ok(EventState::Consumed); + } + Ok(EventState::NotConsumed) + } +} + +fn tree_nav(tree: &mut DatabaseTree, key: Key) -> bool { + if let Some(common_nav) = common_nav(key) { + tree.move_selection(common_nav) + } else { + false + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..af21f1c --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,85 @@ +pub mod command; +pub mod databases; +pub mod utils; + +pub use command::{CommandInfo, CommandText}; +pub use databases::DatabasesComponent; + +use anyhow::Result; +use std::convert::From; +use tui::{backend::Backend, layout::Rect, Frame}; + +#[derive(Copy, Clone)] +pub enum ScrollType { + Up, + Down, + Home, + End, + PageUp, + PageDown, +} + +#[derive(Copy, Clone)] +pub enum Direction { + Up, + Down, +} + +#[derive(PartialEq)] +pub enum CommandBlocking { + Blocking, + PassingOn, +} + +pub trait DrawableComponent { + /// + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()>; +} + +#[derive(PartialEq)] +pub enum EventState { + Consumed, + NotConsumed, +} + +impl From for EventState { + fn from(consumed: bool) -> Self { + if consumed { + Self::Consumed + } else { + Self::NotConsumed + } + } +} + +/// base component trait +pub trait Component { + fn commands(&self, out: &mut Vec, force_all: bool) -> CommandBlocking; + + fn event(&mut self, key: crate::event::Key) -> Result; + + fn focused(&self) -> bool { + false + } + + fn focus(&mut self, _focus: bool) {} + + fn is_visible(&self) -> bool { + true + } + + fn hide(&mut self) {} + + fn show(&mut self) -> Result<()> { + Ok(()) + } + + fn toggle_visible(&mut self) -> Result<()> { + if self.is_visible() { + self.hide(); + Ok(()) + } else { + self.show() + } + } +} diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs new file mode 100644 index 0000000..5860c6c --- /dev/null +++ b/src/components/utils/mod.rs @@ -0,0 +1 @@ +pub mod scroll_vertical; diff --git a/src/components/utils/scroll_vertical.rs b/src/components/utils/scroll_vertical.rs new file mode 100644 index 0000000..ceb5fc6 --- /dev/null +++ b/src/components/utils/scroll_vertical.rs @@ -0,0 +1,94 @@ +use std::cell::Cell; + +use tui::{backend::Backend, layout::Rect, Frame}; + +use crate::{components::ScrollType, ui::scrollbar::draw_scrollbar}; + +pub struct VerticalScroll { + top: Cell, + max_top: Cell, +} + +impl VerticalScroll { + pub const fn new() -> Self { + Self { + top: Cell::new(0), + max_top: Cell::new(0), + } + } + + pub fn get_top(&self) -> usize { + self.top.get() + } + + pub fn reset(&self) { + self.top.set(0); + } + + pub fn _move_top(&self, move_type: ScrollType) -> bool { + let old = self.top.get(); + let max = self.max_top.get(); + + let new_scroll_top = match move_type { + ScrollType::Down => old.saturating_add(1), + ScrollType::Up => old.saturating_sub(1), + ScrollType::Home => 0, + ScrollType::End => max, + _ => old, + }; + + let new_scroll_top = new_scroll_top.clamp(0, max); + + if new_scroll_top == old { + return false; + } + + self.top.set(new_scroll_top); + + true + } + + pub fn update(&self, selection: usize, selection_max: usize, visual_height: usize) -> usize { + let new_top = calc_scroll_top(self.get_top(), visual_height, selection, selection_max); + self.top.set(new_top); + + if visual_height == 0 { + self.max_top.set(0); + } else { + let new_max = selection_max.saturating_sub(visual_height); + self.max_top.set(new_max); + } + + new_top + } + + pub fn _update_no_selection(&self, line_count: usize, visual_height: usize) -> usize { + self.update(self.get_top(), line_count, visual_height) + } + + pub fn draw(&self, f: &mut Frame, r: Rect) { + draw_scrollbar(f, r, self.max_top.get(), self.top.get()); + } +} + +const fn calc_scroll_top( + current_top: usize, + height_in_lines: usize, + selection: usize, + selection_max: usize, +) -> usize { + if height_in_lines == 0 { + return 0; + } + if selection_max <= height_in_lines { + return 0; + } + + if current_top + height_in_lines <= selection { + selection.saturating_sub(height_in_lines) + 1 + } else if current_top > selection { + selection + } else { + current_top + } +} diff --git a/src/event/events.rs b/src/event/events.rs index b004da6..afa3aba 100644 --- a/src/event/events.rs +++ b/src/event/events.rs @@ -21,6 +21,7 @@ impl Default for EventConfig { } /// An occurred event. +#[derive(Copy, Clone)] pub enum Event { /// An input event occurred. Input(I), diff --git a/src/handlers/connection_list.rs b/src/handlers/connection_list.rs index 377c573..e87b7a1 100644 --- a/src/handlers/connection_list.rs +++ b/src/handlers/connection_list.rs @@ -1,7 +1,9 @@ -use crate::app::{App, Database, FocusBlock}; +use crate::app::{App, FocusBlock}; use crate::event::Key; -use crate::utils::get_databases; +use crate::utils::{get_databases, get_tables}; +use database_tree::{Database, DatabaseTree}; use sqlx::mysql::MySqlPool; +use std::collections::BTreeSet; pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { match key { @@ -17,16 +19,28 @@ pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { } let pool = MySqlPool::connect(conn.database_url().as_str()).await?; app.pool = Some(pool); - app.focus_block = FocusBlock::DabataseList(false); + app.focus_block = FocusBlock::DabataseList; } - app.databases = match app.selected_connection() { - Some(conn) => match &conn.database { + if let Some(conn) = app.selected_connection() { + match &conn.database { Some(database) => { - vec![Database::new(database.clone(), app.pool.as_ref().unwrap()).await?] + app.databases.tree = DatabaseTree::new( + &[Database::new( + database.clone(), + get_tables(database.clone(), app.pool.as_ref().unwrap()).await?, + )], + &BTreeSet::new(), + ) + .unwrap() + } + None => { + app.databases.tree = DatabaseTree::new( + get_databases(app.pool.as_ref().unwrap()).await?.as_slice(), + &BTreeSet::new(), + ) + .unwrap() } - None => get_databases(app.pool.as_ref().unwrap()).await?, - }, - None => vec![], + } }; } _ => (), diff --git a/src/handlers/database_list.rs b/src/handlers/database_list.rs index ec1bd7c..2e5c135 100644 --- a/src/handlers/database_list.rs +++ b/src/handlers/database_list.rs @@ -1,22 +1,45 @@ use crate::app::{App, FocusBlock}; +use crate::components::Component as _; use crate::event::Key; +use crate::utils::{get_columns, get_records}; +use database_tree::Database; -pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<()> { - if focused { - match key { - Key::Char('j') => app.next_database(), - Key::Char('k') => app.previous_database(), - Key::Esc => app.focus_block = FocusBlock::DabataseList(false), - _ => (), - } - } else { - match key { - Key::Char('j') => app.focus_block = FocusBlock::TableList(false), - Key::Char('l') => app.focus_block = FocusBlock::RecordTable(false), - Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, - Key::Enter => app.focus_block = FocusBlock::DabataseList(true), - _ => (), +pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { + app.databases.event(key)?; + match key { + Key::Esc => app.focus_block = FocusBlock::DabataseList, + Key::Right => app.focus_block = FocusBlock::RecordTable, + Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, + Key::Enter => { + if let Some((table, database)) = app.databases.tree.selected_table() { + let (headers, records) = get_records( + &Database { + name: database.clone(), + tables: vec![], + }, + &table, + app.pool.as_ref().unwrap(), + ) + .await?; + app.record_table.state.select(Some(0)); + app.record_table.headers = headers; + app.record_table.rows = records; + + let (headers, records) = get_columns( + &Database { + name: database, + tables: vec![], + }, + &table, + app.pool.as_ref().unwrap(), + ) + .await?; + app.structure_table.state.select(Some(0)); + app.structure_table.headers = headers; + app.structure_table.rows = records; + } } + _ => (), } Ok(()) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index b2d43bc..264262b 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -2,37 +2,32 @@ pub mod connection_list; pub mod database_list; pub mod query; pub mod record_table; -pub mod table_list; -use crate::app::{App, FocusBlock}; +use crate::app::{App, FocusBlock, Tab}; use crate::event::Key; pub async fn handle_app(key: Key, app: &mut App) -> anyhow::Result<()> { match app.focus_block { FocusBlock::ConnectionList => connection_list::handler(key, app).await?, - FocusBlock::DabataseList(focused) => database_list::handler(key, app, focused).await?, - FocusBlock::TableList(focused) => table_list::handler(key, app, focused).await?, - FocusBlock::RecordTable(focused) => record_table::handler(key, app, focused).await?, - FocusBlock::Query(focused) => query::handler(key, app, focused).await?, + FocusBlock::DabataseList => database_list::handler(key, app).await?, + FocusBlock::RecordTable => record_table::handler(key, app).await?, + FocusBlock::Query => query::handler(key, app).await?, } match key { Key::Char('d') => match app.focus_block { - FocusBlock::Query(true) => (), - _ => app.focus_block = FocusBlock::DabataseList(true), - }, - Key::Char('t') => match app.focus_block { - FocusBlock::Query(true) => (), - _ => app.focus_block = FocusBlock::TableList(true), + FocusBlock::Query => (), + _ => app.focus_block = FocusBlock::DabataseList, }, Key::Char('r') => match app.focus_block { - FocusBlock::Query(true) => (), - _ => app.focus_block = FocusBlock::RecordTable(true), + FocusBlock::Query => (), + _ => app.focus_block = FocusBlock::RecordTable, }, - Key::Char('e') => app.focus_block = FocusBlock::Query(true), - Key::Right => app.next_tab(), - Key::Left => app.previous_tab(), + Key::Char('e') => app.focus_block = FocusBlock::Query, + Key::Char('1') => app.selected_tab = Tab::Records, + Key::Char('2') => app.selected_tab = Tab::Structure, Key::Esc => app.error = None, _ => (), } + app.databases.focused = matches!(app.focus_block, FocusBlock::DabataseList); Ok(()) } diff --git a/src/handlers/query.rs b/src/handlers/query.rs index 8003e13..6f7e390 100644 --- a/src/handlers/query.rs +++ b/src/handlers/query.rs @@ -6,8 +6,8 @@ use regex::Regex; use sqlx::Row; use unicode_width::UnicodeWidthStr; -pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<()> { - if focused { +pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { + if true { match key { Key::Enter => { app.query = app.input.drain(..).collect(); @@ -58,15 +58,15 @@ pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<( } Key::Left => app.decrement_input_cursor_x(), Key::Right => app.increment_input_cursor_x(), - Key::Esc => app.focus_block = FocusBlock::Query(false), + Key::Esc => app.focus_block = FocusBlock::Query, _ => {} } } else { match key { - Key::Char('h') => app.focus_block = FocusBlock::DabataseList(false), - Key::Char('j') => app.focus_block = FocusBlock::RecordTable(false), + Key::Char('h') => app.focus_block = FocusBlock::DabataseList, + Key::Char('j') => app.focus_block = FocusBlock::RecordTable, Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, - Key::Enter => app.focus_block = FocusBlock::Query(true), + Key::Enter => app.focus_block = FocusBlock::Query, _ => (), } } diff --git a/src/handlers/record_table.rs b/src/handlers/record_table.rs index e39817b..2ef9249 100644 --- a/src/handlers/record_table.rs +++ b/src/handlers/record_table.rs @@ -1,23 +1,15 @@ use crate::app::{App, FocusBlock}; use crate::event::Key; -pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<()> { - if focused { - match key { - Key::Char('h') => app.record_table.previous_column(), - Key::Char('j') => app.record_table.next(), - Key::Char('k') => app.record_table.previous(), - Key::Char('l') => app.record_table.next_column(), - Key::Esc => app.focus_block = FocusBlock::RecordTable(false), - _ => (), - } - } else { - match key { - Key::Char('h') => app.focus_block = FocusBlock::TableList(false), - Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, - Key::Enter => app.focus_block = FocusBlock::RecordTable(true), - _ => (), - } +pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { + match key { + Key::Char('h') => app.record_table.previous_column(), + Key::Char('j') => app.record_table.next(), + Key::Char('k') => app.record_table.previous(), + Key::Char('l') => app.record_table.next_column(), + Key::Left => app.focus_block = FocusBlock::DabataseList, + Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, + _ => (), } Ok(()) } diff --git a/src/handlers/table_list.rs b/src/handlers/table_list.rs deleted file mode 100644 index 18376bc..0000000 --- a/src/handlers/table_list.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::app::{App, FocusBlock}; -use crate::event::Key; -use crate::utils::{get_columns, get_records}; - -pub async fn handler(key: Key, app: &mut App, focused: bool) -> anyhow::Result<()> { - if focused { - match key { - Key::Char('j') => { - if app.selected_database.selected().is_some() { - app.next_table(); - app.record_table.column_index = 0; - if let Some(database) = app.selected_database() { - if let Some(table) = app.selected_table() { - let (headers, records) = - get_records(database, table, app.pool.as_ref().unwrap()).await?; - app.record_table.state.select(Some(0)); - app.record_table.headers = headers; - app.record_table.rows = records; - } - } - - app.structure_table.column_index = 0; - if let Some(database) = app.selected_database() { - if let Some(table) = app.selected_table() { - let (headers, records) = - get_columns(database, table, app.pool.as_ref().unwrap()).await?; - app.structure_table.state.select(Some(0)); - app.structure_table.headers = headers; - app.structure_table.rows = records; - } - } - } - } - Key::Char('k') => { - if app.selected_database.selected().is_some() { - app.previous_table(); - app.record_table.column_index = 0; - if let Some(database) = app.selected_database() { - if let Some(table) = app.selected_table() { - let (headers, records) = - get_records(database, table, app.pool.as_ref().unwrap()).await?; - app.record_table.state.select(Some(0)); - app.record_table.headers = headers; - app.record_table.rows = records; - } - } - - app.structure_table.column_index = 0; - if let Some(database) = app.selected_database() { - if let Some(table) = app.selected_table() { - let (headers, records) = - get_columns(database, table, app.pool.as_ref().unwrap()).await?; - app.structure_table.state.select(Some(0)); - app.structure_table.headers = headers; - app.structure_table.rows = records; - } - } - } - } - Key::Esc => app.focus_block = FocusBlock::TableList(false), - _ => (), - } - } else { - match key { - Key::Char('k') => app.focus_block = FocusBlock::DabataseList(false), - Key::Char('l') => app.focus_block = FocusBlock::RecordTable(false), - Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, - Key::Enter => app.focus_block = FocusBlock::TableList(true), - _ => (), - } - } - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index dd75779..df5b53b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod components; mod event; mod handlers; mod ui; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0dc0255..4811efc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,7 @@ use crate::app::{App, FocusBlock, Tab}; +use crate::components::DrawableComponent as _; +use crate::event::Key; +use database_tree::MoveSelection; use tui::{ backend::Backend, layout::{Constraint, Direction, Layout, Rect}, @@ -9,6 +12,9 @@ use tui::{ }; use unicode_width::UnicodeWidthStr; +pub mod scrollbar; +pub mod scrolllist; + pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<()> { if let FocusBlock::ConnectionList = app.focus_block { let percent_x = 60; @@ -62,51 +68,10 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( .split(f.size()); let left_chunks = Layout::default() - .constraints( - [ - Constraint::Length(9), - Constraint::Min(8), - Constraint::Length(7), - ] - .as_ref(), - ) + .constraints([Constraint::Min(8), Constraint::Length(7)].as_ref()) .split(main_chunks[0]); - let databases: Vec = app - .databases - .iter() - .map(|i| { - ListItem::new(vec![Spans::from(Span::raw(&i.name))]) - .style(Style::default().fg(Color::White)) - }) - .collect(); - let tasks = List::new(databases) - .block(Block::default().borders(Borders::ALL).title("Databases")) - .highlight_style(Style::default().fg(Color::Green)) - .style(match app.focus_block { - FocusBlock::DabataseList(false) => Style::default(), - FocusBlock::DabataseList(true) => Style::default().fg(Color::Green), - _ => Style::default().fg(Color::DarkGray), - }); - f.render_stateful_widget(tasks, left_chunks[0], &mut app.selected_database); - let databases = app.databases.clone(); - let tables: Vec = databases[app.selected_database.selected().unwrap_or(0)] - .tables - .iter() - .map(|i| { - ListItem::new(vec![Spans::from(Span::raw(&i.name))]) - .style(Style::default().fg(Color::White)) - }) - .collect(); - let tasks = List::new(tables) - .block(Block::default().borders(Borders::ALL).title("Tables")) - .highlight_style(Style::default().fg(Color::Green)) - .style(match app.focus_block { - FocusBlock::TableList(false) => Style::default(), - FocusBlock::TableList(true) => Style::default().fg(Color::Green), - _ => Style::default().fg(Color::DarkGray), - }); - f.render_stateful_widget(tasks, left_chunks[1], &mut app.selected_table); + app.databases.draw(f, left_chunks[0]).unwrap(); let table_status: Vec = app .table_status() @@ -119,7 +84,7 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( let tasks = List::new(table_status) .block(Block::default().borders(Borders::ALL)) .highlight_style(Style::default().fg(Color::Green)); - f.render_widget(tasks, left_chunks[2]); + f.render_widget(tasks, left_chunks[1]); let right_chunks = Layout::default() .direction(Direction::Vertical) @@ -147,20 +112,23 @@ pub fn draw(f: &mut Frame<'_, B>, app: &mut App) -> anyhow::Result<( let query = Paragraph::new(app.input.as_ref()) .style(match app.focus_block { - FocusBlock::Query(true) => Style::default().fg(Color::Green), - FocusBlock::Query(false) => Style::default(), + FocusBlock::Query => Style::default(), _ => Style::default().fg(Color::DarkGray), }) .block(Block::default().borders(Borders::ALL).title("Query")); f.render_widget(query, right_chunks[1]); - if let FocusBlock::Query(true) = app.focus_block { + if let FocusBlock::Query = app.focus_block { f.set_cursor( right_chunks[1].x + app.input.width() as u16 + 1 - app.input_cursor_x, right_chunks[1].y + 1, ) } match app.selected_tab { - Tab::Records => draw_records_table(f, app, right_chunks[2])?, + Tab::Records => app.record_table.draw( + f, + right_chunks[2], + matches!(app.focus_block, FocusBlock::RecordTable), + )?, Tab::Structure => draw_structure_table(f, app, right_chunks[2])?, } if let Some(err) = app.error.clone() { @@ -200,8 +168,7 @@ fn draw_structure_table( .block(Block::default().borders(Borders::ALL).title("Structure")) .highlight_style(Style::default().fg(Color::Green)) .style(match app.focus_block { - FocusBlock::RecordTable(false) => Style::default(), - FocusBlock::RecordTable(true) => Style::default().fg(Color::Green), + FocusBlock::RecordTable => Style::default(), _ => Style::default().fg(Color::DarkGray), }) .widths(&widths); @@ -209,46 +176,6 @@ fn draw_structure_table( Ok(()) } -fn draw_records_table( - f: &mut Frame<'_, B>, - app: &mut App, - layout_chunk: Rect, -) -> anyhow::Result<()> { - let headers = app.record_table.headers(); - let header_cells = headers - .iter() - .map(|h| Cell::from(h.to_string()).style(Style::default().fg(Color::White))); - let header = Row::new(header_cells).height(1).bottom_margin(1); - let rows = app.record_table.rows(); - let rows = rows.iter().map(|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().fg(Color::White))); - Row::new(cells).height(height as u16).bottom_margin(1) - }); - let widths = (0..10) - .map(|_| Constraint::Percentage(10)) - .collect::>(); - let t = Table::new(rows) - .header(header) - .block(Block::default().borders(Borders::ALL).title("Records")) - .highlight_style(Style::default().fg(Color::Green)) - .style(match app.focus_block { - FocusBlock::RecordTable(false) => Style::default(), - FocusBlock::RecordTable(true) => Style::default().fg(Color::Green), - _ => Style::default().fg(Color::DarkGray), - }) - .widths(&widths); - f.render_stateful_widget(t, layout_chunk, &mut app.record_table.state); - Ok(()) -} - fn draw_error_popup(f: &mut Frame<'_, B>, error: String) -> anyhow::Result<()> { let percent_x = 60; let percent_y = 20; @@ -282,3 +209,21 @@ fn draw_error_popup(f: &mut Frame<'_, B>, error: String) -> anyhow:: f.render_widget(error, area); Ok(()) } + +pub fn common_nav(key: Key) -> Option { + if key == Key::Char('j') { + Some(MoveSelection::Down) + } else if key == Key::Char('k') { + Some(MoveSelection::Up) + } else if key == Key::PageUp { + Some(MoveSelection::PageUp) + } else if key == Key::PageDown { + Some(MoveSelection::PageDown) + } else if key == Key::Char('l') { + Some(MoveSelection::Right) + } else if key == Key::Char('h') { + Some(MoveSelection::Left) + } else { + None + } +} diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs new file mode 100644 index 0000000..44d7f5e --- /dev/null +++ b/src/ui/scrollbar.rs @@ -0,0 +1,75 @@ +use easy_cast::CastFloat; +use std::convert::TryFrom; +use tui::{ + backend::Backend, + buffer::Buffer, + layout::{Margin, Rect}, + style::Style, + symbols::{block::FULL, line::DOUBLE_VERTICAL}, + widgets::Widget, + Frame, +}; + +/// +struct Scrollbar { + max: u16, + pos: u16, + style_bar: Style, + style_pos: Style, +} + +impl Scrollbar { + fn new(max: usize, pos: usize) -> Self { + Self { + max: u16::try_from(max).unwrap_or_default(), + pos: u16::try_from(pos).unwrap_or_default(), + style_pos: Style::default(), + style_bar: Style::default(), + } + } +} + +impl Widget for Scrollbar { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.height <= 2 { + return; + } + + if self.max == 0 { + return; + } + + let right = area.right().saturating_sub(1); + if right <= area.left() { + return; + }; + + let (bar_top, bar_height) = { + let scrollbar_area = area.inner(&Margin { + horizontal: 0, + vertical: 1, + }); + + (scrollbar_area.top(), scrollbar_area.height) + }; + + for y in bar_top..(bar_top + bar_height) { + buf.set_string(right, y, DOUBLE_VERTICAL, self.style_bar); + } + + let progress = f32::from(self.pos) / f32::from(self.max); + let progress = if progress > 1.0 { 1.0 } else { progress }; + let pos = f32::from(bar_height) * progress; + + let pos: u16 = pos.cast_nearest(); + let pos = pos.saturating_sub(1); + + buf.set_string(right, bar_top + pos, FULL, self.style_pos); + } +} + +pub fn draw_scrollbar(f: &mut Frame, r: Rect, max: usize, pos: usize) { + let mut widget = Scrollbar::new(max, pos); + widget.style_pos = Style::default(); + f.render_widget(widget, r); +} diff --git a/src/ui/scrolllist.rs b/src/ui/scrolllist.rs new file mode 100644 index 0000000..2f990b5 --- /dev/null +++ b/src/ui/scrolllist.rs @@ -0,0 +1,61 @@ +use std::iter::Iterator; +use tui::{ + backend::Backend, + buffer::Buffer, + layout::Rect, + style::Style, + text::Span, + widgets::{Block, List, ListItem, Widget}, + Frame, +}; + +/// +struct ScrollableList<'b, L> +where + L: Iterator>, +{ + block: Option>, + /// Items to be displayed + items: L, + /// Base style of the widget + style: Style, +} + +impl<'b, L> ScrollableList<'b, L> +where + L: Iterator>, +{ + fn new(items: L) -> Self { + Self { + block: None, + items, + style: Style::default(), + } + } + + fn block(mut self, block: Block<'b>) -> Self { + self.block = Some(block); + self + } +} + +impl<'b, L> Widget for ScrollableList<'b, L> +where + L: Iterator>, +{ + fn render(self, area: Rect, buf: &mut Buffer) { + // Render items + List::new(self.items.map(ListItem::new).collect::>()) + .block(self.block.unwrap_or_default()) + .style(self.style) + .render(area, buf); + } +} + +pub fn draw_list_block<'b, B: Backend, L>(f: &mut Frame, r: Rect, block: Block<'b>, items: L) +where + L: Iterator>, +{ + let list = ScrollableList::new(items).block(block); + f.render_widget(list, r); +} diff --git a/src/utils.rs b/src/utils.rs index e21036a..a75d311 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ -use crate::app::{Database, Table}; use chrono::NaiveDate; +use database_tree::{Database, Table}; use futures::TryStreamExt; use sqlx::mysql::{MySqlColumn, MySqlPool, MySqlRow}; use sqlx::{Column as _, Row, TypeInfo}; @@ -13,7 +13,10 @@ pub async fn get_databases(pool: &MySqlPool) -> anyhow::Result> { .collect::>(); let mut list = vec![]; for db in databases { - list.push(Database::new(db, pool).await?) + list.push(Database::new( + db.clone(), + get_tables(db.clone(), pool).await?, + )) } Ok(list) }