diff --git a/Cargo.lock b/Cargo.lock index 6cf1c13..a762513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "ahash" version = "0.7.4" @@ -92,6 +98,30 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e11e16035ea35e4e5997b393eacbf6f63983188f7a2ad25bfb13465f5ad59de" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.2.1" @@ -192,6 +222,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.1" @@ -359,6 +398,34 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "fancy-regex" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6b8560a05112eb52f04b00e5d3790c0dd75d9d980eb8a122fb23b92a623ccf" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "flate2" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80edafed416a46fb378521624fab1cfa2eb514784fd8921adbe8a8d8321da811" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -522,9 +589,11 @@ dependencies = [ "structopt", "strum", "strum_macros", + "syntect", "tokio", "toml", "tui", + "unicode-segmentation", "unicode-width", "which", ] @@ -644,6 +713,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lexical-core" version = "0.7.6" @@ -680,6 +755,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-wrap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" +dependencies = [ + "safemem", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + [[package]] name = "lock_api" version = "0.4.4" @@ -727,6 +817,16 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg 1.0.1", +] + [[package]] name = "mio" version = "0.7.13" @@ -924,6 +1024,20 @@ version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" +[[package]] +name = "plist" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38d026d73eeaf2ade76309d0c65db5a35ecf649e3cec428db316243ea9d6711" +dependencies = [ + "base64", + "chrono", + "indexmap", + "line-wrap", + "serde", + "xml-rs", +] + [[package]] name = "ppv-lite86" version = "0.2.10" @@ -1131,6 +1245,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -1480,6 +1609,28 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "syntect" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b20815bbe80ee0be06e6957450a841185fcf690fe0178f14d77a05ce2caa031" +dependencies = [ + "bincode", + "bitflags", + "fancy-regex", + "flate2", + "fnv", + "lazy_static", + "lazycell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "walkdir", + "yaml-rust", +] + [[package]] name = "tap" version = "1.0.1" @@ -1699,6 +1850,17 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "wasi" version = "0.10.2+wasi-snapshot-preview1" @@ -1825,6 +1987,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1837,6 +2008,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" +[[package]] +name = "xml-rs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zeroize" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 95f5b91..16aa221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ rust_decimal = "1.15" dirs-next = "2.0" clap = "2.33.3" structopt = "0.3.22" +syntect = { version = "4.5", default-features = false, features = ["metadata", "default-fancy"]} +unicode-segmentation = "1.7" [target.'cfg(all(target_family="unix",not(target_os="macos")))'.dependencies] which = "4.1" diff --git a/sample.toml b/sample.toml index c6cf3b1..f6f5243 100644 --- a/sample.toml +++ b/sample.toml @@ -5,12 +5,27 @@ host = "localhost" database = "world" port = 3306 +[[conn]] +type = "mysql" +user = "root" +host = "'127.0.0.1'" +database = "world" +port = 3306 + [[conn]] type = "mysql" user = "root" host = "localhost" port = 3306 +[[conn]] +type = "mysql" +user = "tako8ki" +host = "localhost" +port = 3306 +database = "world" +password = "password" + [[conn]] type = "postgres" user = "postgres" @@ -22,6 +37,22 @@ type = "postgres" user = "postgres" host = "localhost" port = 5432 +password = "password" +database = "dvdrental" + +[[conn]] +type = "postgres" +user = "hoge" +host = "localhost" +port = 5432 +password = "hoge" +database = "dvdrental" + +[[conn]] +type = "postgres" +user = "hoge" +host = "localhost" +port = 5432 database = "dvdrental" [[conn]] diff --git a/src/app.rs b/src/app.rs index d08deb7..b10b776 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use crate::{ components::tab::Tab, components::{ command, ConnectionsComponent, DatabasesComponent, ErrorComponent, HelpComponent, - RecordTableComponent, TabComponent, TableComponent, + RecordTableComponent, SqlEditorComponent, TabComponent, TableComponent, }, config::Config, }; @@ -30,6 +30,7 @@ pub struct App { constraint_table: TableComponent, foreign_key_table: TableComponent, index_table: TableComponent, + sql_editor: SqlEditorComponent, focus: Focus, tab: TabComponent, help: HelpComponent, @@ -51,6 +52,7 @@ impl App { constraint_table: TableComponent::new(config.key_config.clone()), foreign_key_table: TableComponent::new(config.key_config.clone()), index_table: TableComponent::new(config.key_config.clone()), + sql_editor: SqlEditorComponent::new(config.key_config.clone()), tab: TabComponent::new(config.key_config.clone()), help: HelpComponent::new(config.key_config.clone()), databases: DatabasesComponent::new(config.key_config.clone()), @@ -84,8 +86,7 @@ impl App { .split(f.size()); self.databases - .draw(f, main_chunks[0], matches!(self.focus, Focus::DabataseList)) - .unwrap(); + .draw(f, main_chunks[0], matches!(self.focus, Focus::DabataseList))?; let right_chunks = Layout::default() .direction(Direction::Vertical) @@ -117,6 +118,10 @@ impl App { self.index_table .draw(f, right_chunks[1], matches!(self.focus, Focus::Table))? } + Tab::Sql => { + self.sql_editor + .draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?; + } } self.error.draw(f, Rect::default(), false)?; self.help.draw(f, Rect::default(), false)?; @@ -437,6 +442,17 @@ impl App { } }; } + Tab::Sql => { + if self.sql_editor.event(key)?.is_consumed() + || self + .sql_editor + .async_event(key, self.pool.as_ref().unwrap()) + .await? + .is_consumed() + { + return Ok(EventState::Consumed); + }; + } }; } } diff --git a/src/components/command.rs b/src/components/command.rs index 192b870..c17186c 100644 --- a/src/components/command.rs +++ b/src/components/command.rs @@ -131,6 +131,10 @@ pub fn tab_indexes(key: &KeyConfig) -> CommandText { CommandText::new(format!("Indexes [{}]", key.tab_indexes), CMD_GROUP_TABLE) } +pub fn tab_sql_editor(key: &KeyConfig) -> CommandText { + CommandText::new(format!("SQL [{}]", key.tab_sql_editor), CMD_GROUP_TABLE) +} + pub fn toggle_tabs(key_config: &KeyConfig) -> CommandText { CommandText::new( format!( diff --git a/src/components/completion.rs b/src/components/completion.rs index df643c5..848c355 100644 --- a/src/components/completion.rs +++ b/src/components/completion.rs @@ -11,7 +11,10 @@ use tui::{ Frame, }; -const RESERVED_WORDS: &[&str] = &["IN", "AND", "OR", "NOT", "NULL", "IS"]; +const RESERVED_WORDS_IN_WHERE_CLAUSE: &[&str] = &["IN", "AND", "OR", "NOT", "NULL", "IS"]; +const ALL_RESERVED_WORDS: &[&str] = &[ + "IN", "AND", "OR", "NOT", "NULL", "IS", "SELECT", "UPDATE", "DELETE", "FROM", "LIMIT", "WHERE", +]; pub struct CompletionComponent { key_config: KeyConfig, @@ -21,12 +24,19 @@ pub struct CompletionComponent { } impl CompletionComponent { - pub fn new(key_config: KeyConfig, word: impl Into) -> Self { + pub fn new(key_config: KeyConfig, word: impl Into, all: bool) -> Self { Self { key_config, state: ListState::default(), word: word.into(), - candidates: RESERVED_WORDS.iter().map(|w| w.to_string()).collect(), + candidates: if all { + ALL_RESERVED_WORDS.iter().map(|w| w.to_string()).collect() + } else { + RESERVED_WORDS_IN_WHERE_CLAUSE + .iter() + .map(|w| w.to_string()) + .collect() + }, } } @@ -145,7 +155,7 @@ mod test { #[test] fn test_filterd_candidates_lowercase() { assert_eq!( - CompletionComponent::new(KeyConfig::default(), "an") + CompletionComponent::new(KeyConfig::default(), "an", false) .filterd_candidates() .collect::>(), vec![&"AND".to_string()] @@ -155,7 +165,7 @@ mod test { #[test] fn test_filterd_candidates_uppercase() { assert_eq!( - CompletionComponent::new(KeyConfig::default(), "AN") + CompletionComponent::new(KeyConfig::default(), "AN", false) .filterd_candidates() .collect::>(), vec![&"AND".to_string()] @@ -165,14 +175,14 @@ mod test { #[test] fn test_filterd_candidates_multiple_candidates() { assert_eq!( - CompletionComponent::new(KeyConfig::default(), "n") + CompletionComponent::new(KeyConfig::default(), "n", false) .filterd_candidates() .collect::>(), vec![&"NOT".to_string(), &"NULL".to_string()] ); assert_eq!( - CompletionComponent::new(KeyConfig::default(), "N") + CompletionComponent::new(KeyConfig::default(), "N", false) .filterd_candidates() .collect::>(), vec![&"NOT".to_string(), &"NULL".to_string()] diff --git a/src/components/mod.rs b/src/components/mod.rs index 44c6e6d..4b9f187 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -6,6 +6,7 @@ pub mod databases; pub mod error; pub mod help; pub mod record_table; +pub mod sql_editor; pub mod tab; pub mod table; pub mod table_filter; @@ -24,6 +25,7 @@ pub use databases::DatabasesComponent; pub use error::ErrorComponent; pub use help::HelpComponent; pub use record_table::RecordTableComponent; +pub use sql_editor::SqlEditorComponent; pub use tab::TabComponent; pub use table::TableComponent; pub use table_filter::TableFilterComponent; @@ -33,6 +35,7 @@ pub use table_value::TableValueComponent; #[cfg(debug_assertions)] pub use debug::DebugComponent; +use crate::database::Pool; use anyhow::Result; use async_trait::async_trait; use std::convert::TryInto; @@ -87,6 +90,14 @@ pub trait Component { fn event(&mut self, key: crate::event::Key) -> Result; + async fn async_event( + &mut self, + _key: crate::event::Key, + _pool: &Box, + ) -> Result { + Ok(EventState::NotConsumed) + } + fn focused(&self) -> bool { false } diff --git a/src/components/sql_editor.rs b/src/components/sql_editor.rs new file mode 100644 index 0000000..841ec3e --- /dev/null +++ b/src/components/sql_editor.rs @@ -0,0 +1,289 @@ +use super::{ + compute_character_width, CompletionComponent, Component, EventState, MovableComponent, + StatefulDrawableComponent, TableComponent, +}; +use crate::components::command::CommandInfo; +use crate::config::KeyConfig; +use crate::database::{ExecuteResult, Pool}; +use crate::event::Key; +use crate::ui::stateful_paragraph::{ParagraphState, StatefulParagraph}; +use anyhow::Result; +use async_trait::async_trait; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; +use unicode_width::UnicodeWidthStr; + +struct QueryResult { + updated_rows: u64, + query: String, +} + +impl QueryResult { + fn result_str(&self) -> String { + format!("Query OK, {} row affected", self.updated_rows) + } +} + +pub enum Focus { + Editor, + Table, +} + +pub struct SqlEditorComponent { + input: Vec, + input_cursor_position_x: u16, + input_idx: usize, + table: TableComponent, + query_result: Option, + completion: CompletionComponent, + key_config: KeyConfig, + paragraph_state: ParagraphState, + focus: Focus, +} + +impl SqlEditorComponent { + pub fn new(key_config: KeyConfig) -> Self { + Self { + input: Vec::new(), + input_idx: 0, + input_cursor_position_x: 0, + table: TableComponent::new(key_config.clone()), + completion: CompletionComponent::new(key_config.clone(), "", true), + focus: Focus::Editor, + paragraph_state: ParagraphState::default(), + query_result: None, + key_config, + } + } + + fn update_completion(&mut self) { + let input = &self + .input + .iter() + .enumerate() + .filter(|(i, _)| i < &self.input_idx) + .map(|(_, i)| i) + .collect::() + .split(' ') + .map(|i| i.to_string()) + .collect::>(); + self.completion + .update(input.last().unwrap_or(&String::new())); + } + + fn complete(&mut self) -> anyhow::Result { + if let Some(candidate) = self.completion.selected_candidate() { + let mut input = Vec::new(); + let first = self + .input + .iter() + .enumerate() + .filter(|(i, _)| i < &self.input_idx.saturating_sub(self.completion.word().len())) + .map(|(_, c)| c.to_string()) + .collect::>(); + let last = self + .input + .iter() + .enumerate() + .filter(|(i, _)| i >= &self.input_idx) + .map(|(_, c)| c.to_string()) + .collect::>(); + + let is_last_word = last.first().map_or(false, |c| c == &" ".to_string()); + + let middle = if is_last_word { + candidate + .chars() + .map(|c| c.to_string()) + .collect::>() + } else { + let mut c = candidate + .chars() + .map(|c| c.to_string()) + .collect::>(); + c.push(" ".to_string()); + c + }; + + input.extend(first); + input.extend(middle.clone()); + input.extend(last); + + self.input = input.join("").chars().collect(); + self.input_idx += &middle.len(); + if is_last_word { + self.input_idx += 1; + } + self.input_idx -= self.completion.word().len(); + self.input_cursor_position_x += middle + .join("") + .chars() + .map(compute_character_width) + .sum::(); + if is_last_word { + self.input_cursor_position_x += " ".to_string().width() as u16 + } + self.input_cursor_position_x -= self + .completion + .word() + .chars() + .map(compute_character_width) + .sum::(); + self.update_completion(); + return Ok(EventState::Consumed); + } + Ok(EventState::NotConsumed) + } +} + +impl StatefulDrawableComponent for SqlEditorComponent { + fn draw(&mut self, f: &mut Frame, area: Rect, focused: bool) -> Result<()> { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(if matches!(self.focus, Focus::Table) { + vec![Constraint::Length(7), Constraint::Min(1)] + } else { + vec![Constraint::Percentage(50), Constraint::Min(1)] + }) + .split(area); + + let editor = StatefulParagraph::new(self.input.iter().collect::()) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL)); + + f.render_stateful_widget(editor, layout[0], &mut self.paragraph_state); + + if let Some(result) = self.query_result.as_ref() { + let result = Paragraph::new(result.result_str()) + .block(Block::default().borders(Borders::ALL).style( + if focused && matches!(self.focus, Focus::Editor) { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }, + )) + .wrap(Wrap { trim: true }); + f.render_widget(result, layout[1]); + } else { + self.table + .draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?; + } + + if focused && matches!(self.focus, Focus::Editor) { + f.set_cursor( + (layout[0].x + 1) + .saturating_add( + self.input_cursor_position_x % layout[0].width.saturating_sub(2), + ) + .min(area.right().saturating_sub(2)), + (layout[0].y + + 1 + + self.input_cursor_position_x / layout[0].width.saturating_sub(2)) + .min(layout[0].bottom()), + ) + } + + if focused && matches!(self.focus, Focus::Editor) { + self.completion.draw( + f, + area, + false, + self.input_cursor_position_x % layout[0].width.saturating_sub(2) + 1, + self.input_cursor_position_x / layout[0].width.saturating_sub(2), + )?; + }; + Ok(()) + } +} + +#[async_trait] +impl Component for SqlEditorComponent { + fn commands(&self, _out: &mut Vec) {} + + fn event(&mut self, key: Key) -> Result { + let input_str: String = self.input.iter().collect(); + + if key == self.key_config.focus_above && matches!(self.focus, Focus::Table) { + self.focus = Focus::Editor + } else if key == self.key_config.enter { + return self.complete(); + } + + match key { + Key::Char(c) if matches!(self.focus, Focus::Editor) => { + self.input.insert(self.input_idx, c); + self.input_idx += 1; + self.input_cursor_position_x += compute_character_width(c); + self.update_completion(); + + return Ok(EventState::Consumed); + } + Key::Esc if matches!(self.focus, Focus::Editor) => self.focus = Focus::Table, + Key::Delete | Key::Backspace if matches!(self.focus, Focus::Editor) => { + if input_str.width() > 0 && !self.input.is_empty() && self.input_idx > 0 { + let last_c = self.input.remove(self.input_idx - 1); + self.input_idx -= 1; + self.input_cursor_position_x -= compute_character_width(last_c); + self.completion.update(""); + } + + return Ok(EventState::Consumed); + } + Key::Left if matches!(self.focus, Focus::Editor) => { + if !self.input.is_empty() && self.input_idx > 0 { + self.input_idx -= 1; + self.input_cursor_position_x = self + .input_cursor_position_x + .saturating_sub(compute_character_width(self.input[self.input_idx])); + self.completion.update(""); + } + return Ok(EventState::Consumed); + } + Key::Right if matches!(self.focus, Focus::Editor) => { + if self.input_idx < self.input.len() { + let next_c = self.input[self.input_idx]; + self.input_idx += 1; + self.input_cursor_position_x += compute_character_width(next_c); + self.completion.update(""); + } + return Ok(EventState::Consumed); + } + key if matches!(self.focus, Focus::Table) => return self.table.event(key), + _ => (), + } + return Ok(EventState::NotConsumed); + } + + async fn async_event(&mut self, key: Key, pool: &Box) -> Result { + if key == self.key_config.enter && matches!(self.focus, Focus::Editor) { + let query = self.input.iter().collect(); + let result = pool.execute(&query).await?; + match result { + ExecuteResult::Read { + headers, + rows, + database, + table, + } => { + self.table.update(rows, headers, database, table); + self.focus = Focus::Table; + self.query_result = None; + } + ExecuteResult::Write { updated_rows } => { + self.query_result = Some(QueryResult { + updated_rows, + query: query.to_string(), + }) + } + } + return Ok(EventState::Consumed); + } + + Ok(EventState::NotConsumed) + } +} diff --git a/src/components/tab.rs b/src/components/tab.rs index dc276f1..e2356c1 100644 --- a/src/components/tab.rs +++ b/src/components/tab.rs @@ -20,6 +20,7 @@ pub enum Tab { Constraints, ForeignKeys, Indexes, + Sql, } impl std::fmt::Display for Tab { @@ -52,6 +53,7 @@ impl TabComponent { command::tab_constraints(&self.key_config).name, command::tab_foreign_keys(&self.key_config).name, command::tab_indexes(&self.key_config).name, + command::tab_sql_editor(&self.key_config).name, ] } } @@ -90,7 +92,7 @@ impl Component for TabComponent { self.selected_tab = Tab::ForeignKeys; return Ok(EventState::Consumed); } else if key == self.key_config.tab_indexes { - self.selected_tab = Tab::Indexes; + self.selected_tab = Tab::Sql; return Ok(EventState::Consumed); } Ok(EventState::NotConsumed) diff --git a/src/components/table_filter.rs b/src/components/table_filter.rs index 45352df..9875f24 100644 --- a/src/components/table_filter.rs +++ b/src/components/table_filter.rs @@ -34,7 +34,7 @@ impl TableFilterComponent { input: Vec::new(), input_idx: 0, input_cursor_position: 0, - completion: CompletionComponent::new(key_config, ""), + completion: CompletionComponent::new(key_config, "", false), } } diff --git a/src/config.rs b/src/config.rs index e716db4..fbf12b6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,6 +87,7 @@ pub struct KeyConfig { pub exit_popup: Key, pub focus_right: Key, pub focus_left: Key, + pub focus_above: Key, pub focus_connections: Key, pub open_help: Key, pub filter: Key, @@ -105,6 +106,7 @@ pub struct KeyConfig { pub tab_indexes: Key, pub extend_or_shorten_widget_width_to_right: Key, pub extend_or_shorten_widget_width_to_left: Key, + pub tab_sql_editor: Key, } impl Default for KeyConfig { @@ -123,6 +125,7 @@ impl Default for KeyConfig { exit_popup: Key::Esc, focus_right: Key::Right, focus_left: Key::Left, + focus_above: Key::Up, focus_connections: Key::Char('c'), open_help: Key::Char('?'), filter: Key::Char('/'), @@ -141,6 +144,7 @@ impl Default for KeyConfig { tab_indexes: Key::Char('5'), extend_or_shorten_widget_width_to_right: Key::Char('>'), extend_or_shorten_widget_width_to_left: Key::Char('<'), + tab_sql_editor: Key::Char('6'), } } } diff --git a/src/database/mod.rs b/src/database/mod.rs index 91c074a..46a771a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -12,7 +12,8 @@ use database_tree::{Child, Database, Table}; pub const RECORDS_LIMIT_PER_PAGE: u8 = 200; #[async_trait] -pub trait Pool { +pub trait Pool: Send + Sync { + async fn execute(&self, query: &String) -> anyhow::Result; async fn get_databases(&self) -> anyhow::Result>; async fn get_tables(&self, database: String) -> anyhow::Result>; async fn get_records( @@ -45,6 +46,18 @@ pub trait Pool { async fn close(&self); } +pub enum ExecuteResult { + Read { + headers: Vec, + rows: Vec>, + database: Database, + table: Table, + }, + Write { + updated_rows: u64, + }, +} + pub trait TableRow: std::marker::Send { fn fields(&self) -> Vec; fn columns(&self) -> Vec; diff --git a/src/database/mysql.rs b/src/database/mysql.rs index 0fa887a..a5e3971 100644 --- a/src/database/mysql.rs +++ b/src/database/mysql.rs @@ -1,6 +1,6 @@ use crate::get_or_null; -use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; +use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; use async_trait::async_trait; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use database_tree::{Child, Database, Table}; @@ -146,6 +146,49 @@ impl TableRow for Index { #[async_trait] impl Pool for MySqlPool { + async fn execute(&self, query: &String) -> anyhow::Result { + let query = query.trim(); + + if query.starts_with("SELECT") || query.starts_with("select") { + let mut rows = sqlx::query(query).fetch(&self.pool); + let mut headers = vec![]; + let mut records = vec![]; + while let Some(row) = rows.try_next().await? { + headers = row + .columns() + .iter() + .map(|column| column.name().to_string()) + .collect(); + let mut new_row = vec![]; + for column in row.columns() { + new_row.push(convert_column_value_to_string(&row, column)?) + } + records.push(new_row) + } + + return Ok(ExecuteResult::Read { + headers, + rows: records, + database: Database { + name: "-".to_string(), + children: Vec::new(), + }, + table: Table { + name: "-".to_string(), + create_time: None, + update_time: None, + engine: None, + schema: None, + }, + }); + } + + let result = sqlx::query(query).execute(&self.pool).await?; + Ok(ExecuteResult::Write { + updated_rows: result.rows_affected(), + }) + } + async fn get_databases(&self) -> anyhow::Result> { let databases = sqlx::query("SHOW DATABASES") .fetch_all(&self.pool) diff --git a/src/database/postgres.rs b/src/database/postgres.rs index ef82df0..b4e4535 100644 --- a/src/database/postgres.rs +++ b/src/database/postgres.rs @@ -1,6 +1,6 @@ use crate::get_or_null; -use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; +use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; use async_trait::async_trait; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use database_tree::{Child, Database, Schema, Table}; @@ -147,6 +147,47 @@ impl TableRow for Index { #[async_trait] impl Pool for PostgresPool { + async fn execute(&self, query: &String) -> anyhow::Result { + let query = query.trim(); + if query.starts_with("SELECT") || query.starts_with("select") { + let mut rows = sqlx::query(query).fetch(&self.pool); + let mut headers = vec![]; + let mut records = vec![]; + while let Some(row) = rows.try_next().await? { + headers = row + .columns() + .iter() + .map(|column| column.name().to_string()) + .collect(); + let mut new_row = vec![]; + for column in row.columns() { + new_row.push(convert_column_value_to_string(&row, column)?) + } + records.push(new_row) + } + return Ok(ExecuteResult::Read { + headers, + rows: records, + database: Database { + name: "-".to_string(), + children: Vec::new(), + }, + table: Table { + name: "-".to_string(), + create_time: None, + update_time: None, + engine: None, + schema: None, + }, + }); + } + + let result = sqlx::query(query).execute(&self.pool).await?; + Ok(ExecuteResult::Write { + updated_rows: result.rows_affected(), + }) + } + async fn get_databases(&self) -> anyhow::Result> { let databases = sqlx::query("SELECT datname FROM pg_database") .fetch_all(&self.pool) diff --git a/src/database/sqlite.rs b/src/database/sqlite.rs index decd651..295358f 100644 --- a/src/database/sqlite.rs +++ b/src/database/sqlite.rs @@ -1,6 +1,6 @@ use crate::get_or_null; -use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; +use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE}; use async_trait::async_trait; use chrono::NaiveDateTime; use database_tree::{Child, Database, Table}; @@ -150,6 +150,47 @@ impl TableRow for Index { #[async_trait] impl Pool for SqlitePool { + async fn execute(&self, query: &String) -> anyhow::Result { + let query = query.trim(); + if query.starts_with("SELECT") || query.starts_with("select") { + let mut rows = sqlx::query(query).fetch(&self.pool); + let mut headers = vec![]; + let mut records = vec![]; + while let Some(row) = rows.try_next().await? { + headers = row + .columns() + .iter() + .map(|column| column.name().to_string()) + .collect(); + let mut new_row = vec![]; + for column in row.columns() { + new_row.push(convert_column_value_to_string(&row, column)?) + } + records.push(new_row) + } + return Ok(ExecuteResult::Read { + headers, + rows: records, + database: Database { + name: "-".to_string(), + children: Vec::new(), + }, + table: Table { + name: "-".to_string(), + create_time: None, + update_time: None, + engine: None, + schema: None, + }, + }); + } + + let result = sqlx::query(query).execute(&self.pool).await?; + Ok(ExecuteResult::Write { + updated_rows: result.rows_affected(), + }) + } + async fn get_databases(&self) -> anyhow::Result> { let databases = sqlx::query("SELECT name FROM pragma_database_list") .fetch_all(&self.pool) diff --git a/src/log.rs b/src/log.rs index 0a2da10..944c499 100644 --- a/src/log.rs +++ b/src/log.rs @@ -61,3 +61,21 @@ macro_rules! outln { writeln!($config.log_level.write(&$level), $($expr),+).expect("Can't write output"); }} } + +#[macro_export] +macro_rules! debug { + ($($expr:expr),+) => { + #[cfg(debug_assertions)] + { + use std::io::{Write}; + use std::fs::OpenOptions; + let mut file = OpenOptions::new() + .write(true) + .create(true) + .append(true) + .open("gobang.log") + .unwrap(); + writeln!(file, $($expr),+).expect("Can't write output"); + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bc99059..b3e3c50 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,8 +2,11 @@ use crate::config::KeyConfig; use crate::event::Key; use database_tree::MoveSelection; +pub mod reflow; pub mod scrollbar; pub mod scrolllist; +pub mod stateful_paragraph; +pub mod syntax_text; pub fn common_nav(key: Key, key_config: &KeyConfig) -> Option { if key == key_config.scroll_down { diff --git a/src/ui/reflow.rs b/src/ui/reflow.rs new file mode 100644 index 0000000..c699da3 --- /dev/null +++ b/src/ui/reflow.rs @@ -0,0 +1,545 @@ +use easy_cast::Cast; +use tui::text::StyledGrapheme; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +const NBSP: &str = "\u{00a0}"; + +/// A state machine to pack styled symbols into lines. +/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming +/// iterators for that). +pub trait LineComposer<'a> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>; +} + +/// A state machine that wraps lines on word boundaries. +pub struct WordWrapper<'a, 'b> { + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + current_line: Vec>, + next_line: Vec>, + /// Removes the leading whitespace from lines + trim: bool, +} + +impl<'a, 'b> WordWrapper<'a, 'b> { + pub fn new( + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + trim: bool, + ) -> WordWrapper<'a, 'b> { + WordWrapper { + symbols, + max_line_width, + current_line: vec![], + next_line: vec![], + trim, + } + } +} + +impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { + if self.max_line_width == 0 { + return None; + } + std::mem::swap(&mut self.current_line, &mut self.next_line); + self.next_line.truncate(0); + + let mut current_line_width = self + .current_line + .iter() + .map(|StyledGrapheme { symbol, .. }| -> u16 { symbol.width().cast() }) + .sum(); + + let mut symbols_to_last_word_end: usize = 0; + let mut width_to_last_word_end: u16 = 0; + let mut prev_whitespace = false; + let mut symbols_exhausted = true; + for StyledGrapheme { symbol, style } in &mut self.symbols { + symbols_exhausted = false; + let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP; + + // Ignore characters wider that the total max width. + if Cast::::cast(symbol.width()) > self.max_line_width + // Skip leading whitespace when trim is enabled. + || self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0 + { + continue; + } + + // Break on newline and discard it. + if symbol == "\n" { + if prev_whitespace { + current_line_width = width_to_last_word_end; + self.current_line.truncate(symbols_to_last_word_end); + } + break; + } + + // Mark the previous symbol as word end. + if symbol_whitespace && !prev_whitespace { + symbols_to_last_word_end = self.current_line.len(); + width_to_last_word_end = current_line_width; + } + + self.current_line.push(StyledGrapheme { symbol, style }); + current_line_width += Cast::::cast(symbol.width()); + + if current_line_width > self.max_line_width { + // If there was no word break in the text, wrap at the end of the line. + let (truncate_at, truncated_width) = if symbols_to_last_word_end == 0 { + (self.current_line.len() - 1, self.max_line_width) + } else { + (self.current_line.len() - 1, width_to_last_word_end) + }; + + // Push the remainder to the next line but strip leading whitespace: + { + let remainder = &self.current_line[truncate_at..]; + if !remainder.is_empty() { + self.next_line.extend_from_slice(&remainder); + } + } + self.current_line.truncate(truncate_at); + current_line_width = truncated_width; + break; + } + + prev_whitespace = symbol_whitespace; + } + + // Even if the iterator is exhausted, pass the previous remainder. + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// A state machine that truncates overhanging lines. +pub struct LineTruncator<'a, 'b> { + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + current_line: Vec>, + /// Record the offet to skip render + horizontal_offset: u16, +} + +impl<'a, 'b> LineTruncator<'a, 'b> { + pub fn new( + symbols: &'b mut dyn Iterator>, + max_line_width: u16, + ) -> LineTruncator<'a, 'b> { + LineTruncator { + symbols, + max_line_width, + horizontal_offset: 0, + current_line: vec![], + } + } + + pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) { + self.horizontal_offset = horizontal_offset; + } +} + +impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { + if self.max_line_width == 0 { + return None; + } + + self.current_line.truncate(0); + let mut current_line_width = 0; + + let mut skip_rest = false; + let mut symbols_exhausted = true; + let mut horizontal_offset = self.horizontal_offset as usize; + for StyledGrapheme { symbol, style } in &mut self.symbols { + symbols_exhausted = false; + + // Ignore characters wider that the total max width. + if Cast::::cast(symbol.width()) > self.max_line_width { + continue; + } + + // Break on newline and discard it. + if symbol == "\n" { + break; + } + + if current_line_width + Cast::::cast(symbol.width()) > self.max_line_width { + // Exhaust the remainder of the line. + skip_rest = true; + break; + } + + let symbol = if horizontal_offset == 0 { + symbol + } else { + let w = symbol.width(); + if w > horizontal_offset { + let t = trim_offset(symbol, horizontal_offset); + horizontal_offset = 0; + t + } else { + horizontal_offset -= w; + "" + } + }; + current_line_width += Cast::::cast(symbol.width()); + self.current_line.push(StyledGrapheme { symbol, style }); + } + + if skip_rest { + for StyledGrapheme { symbol, .. } in &mut self.symbols { + if symbol == "\n" { + break; + } + } + } + + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// This function will return a str slice which start at specified offset. +/// As src is a unicode str, start offset has to be calculated with each character. +fn trim_offset(src: &str, mut offset: usize) -> &str { + let mut start = 0; + for c in UnicodeSegmentation::graphemes(src, true) { + let w = c.width(); + if w <= offset { + offset -= w; + start += c.len(); + } else { + break; + } + } + &src[start..] +} + +#[cfg(test)] +mod test { + use super::*; + use unicode_segmentation::UnicodeSegmentation; + + enum Composer { + WordWrapper { trim: bool }, + LineTruncator, + } + + fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec, Vec) { + let style = Default::default(); + let mut styled = + UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style }); + let mut composer: Box = match which { + Composer::WordWrapper { trim } => { + Box::new(WordWrapper::new(&mut styled, text_area_width, trim)) + } + Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)), + }; + let mut lines = vec![]; + let mut widths = vec![]; + while let Some((styled, width)) = composer.next_line() { + let line = styled + .iter() + .map(|StyledGrapheme { symbol, .. }| *symbol) + .collect::(); + assert!(width <= text_area_width); + lines.push(line); + widths.push(width); + } + (lines, widths) + } + + #[test] + fn line_composer_one_line() { + let width = 40; + for i in 1..width { + let text = "a".repeat(i); + let (word_wrapper, _) = + run_composer(Composer::WordWrapper { trim: true }, &text, width as u16); + let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16); + let expected = vec![text]; + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, expected); + } + } + + #[test] + fn line_composer_short_lines() { + let width = 20; + let text = + "abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + + let wrapped: Vec<&str> = text.split('\n').collect(); + assert_eq!(word_wrapper, wrapped); + assert_eq!(line_truncator, wrapped); + } + + #[test] + fn line_composer_long_word() { + let width = 20; + let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno"; + let (word_wrapper, _) = + run_composer(Composer::WordWrapper { trim: true }, text, width as u16); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16); + + let wrapped = vec![ + &text[..width], + &text[width..width * 2], + &text[width * 2..width * 3], + &text[width * 3..], + ]; + assert_eq!( + word_wrapper, wrapped, + "WordWrapper should detect the line cannot be broken on word boundary and \ + break it at line width limit." + ); + assert_eq!(line_truncator, vec![&text[..width]]); + } + + #[test] + fn line_composer_long_sentence() { + let width = 20; + let text = + "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l m n o"; + let text_multi_space = + "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \ + m n o"; + let (word_wrapper_single_space, _) = + run_composer(Composer::WordWrapper { trim: true }, text, width as u16); + let (word_wrapper_multi_space, _) = run_composer( + Composer::WordWrapper { trim: true }, + text_multi_space, + width as u16, + ); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16); + + assert_eq!( + word_wrapper_single_space, + vec![ + "abcd efghij klmnopab", + "cd efgh ijklmnopabcd", + "efg hijkl mnopab c d", + " e f g h i j k l m n", + " o", + ] + ); + assert_eq!( + word_wrapper_multi_space, + vec![ + "abcd efghij klmno", + "pabcd efgh ijklm", + "nopabcdefg hijkl mno", + "pab c d e f g h i j ", + "k l m n o" + ] + ); + + assert_eq!(line_truncator, vec![&text[..width]]); + } + + #[test] + fn line_composer_zero_width() { + let width = 0; + let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + + let expected: Vec<&str> = Vec::new(); + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, expected); + } + + #[test] + fn line_composer_max_line_width_of_1() { + let width = 1; + let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + + let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, vec!["a"]); + } + + #[test] + fn line_composer_max_line_width_of_1_double_width_characters() { + let width = 1; + let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\ + 両端点では、"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec!["", "a", "a", "a"]); + assert_eq!(line_truncator, vec!["", "a"]); + } + + /// Tests WordWrapper with words some of which exceed line length and some not. + #[test] + fn line_composer_word_wrapper_mixed_length() { + let width = 20; + let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + assert_eq!( + word_wrapper, + vec![ + "abcd efghij klmnopab", + "cdefghijklmnopabcdef", + "ghijkl mnopab cdefgh", + "i j klmno" + ] + ) + } + + #[test] + fn line_composer_double_width_chars() { + let width = 20; + let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\ + では、"; + let (word_wrapper, word_wrapper_width) = + run_composer(Composer::WordWrapper { trim: true }, &text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width); + assert_eq!(line_truncator, vec!["コンピュータ上で文字"]); + let wrapped = vec![ + "コンピュータ上で文字", + "を扱う場合、典型的に", + "は文字による通信を行", + "う場合にその両端点で", + "は、", + ]; + assert_eq!(word_wrapper, wrapped); + assert_eq!(word_wrapper_width, vec![width, width, width, width, 4]); + } + + #[test] + fn line_composer_leading_whitespace_removal() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]); + assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]); + } + + /// Tests truncation of leading whitespace. + #[test] + fn line_composer_lots_of_spaces() { + let width = 20; + let text = " "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec![""]); + assert_eq!(line_truncator, vec![" "]); + } + + /// Tests an input starting with a letter, folowed by spaces - some of the behaviour is + /// incidental. + #[test] + fn line_composer_char_plus_lots_of_spaces() { + let width = 20; + let text = "a "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + // What's happening below is: the first line gets consumed, trailing spaces discarded, + // after 20 of which a word break occurs (probably shouldn't). The second line break + // discards all whitespace. The result should probably be vec!["a"] but it doesn't matter + // that much. + assert_eq!( + word_wrapper, + vec![ + "a ", + " ", + " ", + " " + ] + ); + assert_eq!(line_truncator, vec!["a "]); + } + + #[test] + fn line_composer_word_wrapper_double_width_chars_mixed_with_spaces() { + let width = 20; + // Japanese seems not to use spaces but we should break on spaces anyway... We're using it + // to test double-width chars. + // You are more than welcome to add word boundary detection based of alterations of + // hiragana and katakana... + // This happens to also be a test case for mixed width because regular spaces are single width. + let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、"; + let (word_wrapper, word_wrapper_width) = + run_composer(Composer::WordWrapper { trim: true }, text, width); + assert_eq!( + word_wrapper, + vec![ + "コンピュ ータ上で文", + "字を扱う場合、 典型", + "的には文 字による 通", + "信を行 う場合にその", + "両端点では、" + ] + ); + // Odd-sized lines have a space in them. + assert_eq!(word_wrapper_width, vec![8, 14, 17, 6, 12]); + } + + /// Ensure words separated by nbsp are wrapped as if they were a single one. + #[test] + fn line_composer_word_wrapper_nbsp() { + let width = 20; + let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA AAAA", "\u{a0}AAA"]); + + // Ensure that if the character was a regular space, it would be wrapped differently. + let text_space = text.replace("\u{00a0}", " "); + let (word_wrapper_space, _) = + run_composer(Composer::WordWrapper { trim: true }, &text_space, width); + assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", " AAA"]); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA"]); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_with_wrap() { + let width = 10; + let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!( + word_wrapper, + vec!["AAA AAA AA", "AAA AA AAA", "AAA", " B", " C", " D"] + ); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() { + let width = 10; + let text = " 4 Indent\n must wrap!"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!( + word_wrapper, + vec![ + " ", + " 4 Ind", + "ent", + " ", + " mus", + "t wrap!" + ] + ); + } +} diff --git a/src/ui/stateful_paragraph.rs b/src/ui/stateful_paragraph.rs new file mode 100644 index 0000000..db4f07a --- /dev/null +++ b/src/ui/stateful_paragraph.rs @@ -0,0 +1,182 @@ +#![allow(dead_code)] + +use easy_cast::Cast; +use std::iter; +use tui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::Style, + text::{StyledGrapheme, Text}, + widgets::{Block, StatefulWidget, Widget, Wrap}, +}; +use unicode_width::UnicodeWidthStr; + +use super::reflow::{LineComposer, LineTruncator, WordWrapper}; + +const fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 { + match alignment { + Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2), + Alignment::Right => text_area_width.saturating_sub(line_width), + Alignment::Left => 0, + } +} + +#[derive(Debug, Clone)] +pub struct StatefulParagraph<'a> { + /// A block to wrap the widget in + block: Option>, + /// Widget style + style: Style, + /// How to wrap the text + wrap: Option, + /// The text to display + text: Text<'a>, + /// Alignment of the text + alignment: Alignment, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ScrollPos { + pub x: u16, + pub y: u16, +} + +impl ScrollPos { + pub const fn new(x: u16, y: u16) -> Self { + Self { x, y } + } +} + +#[derive(Debug, Copy, Clone, Default)] +pub struct ParagraphState { + /// Scroll + scroll: ScrollPos, + /// after all wrapping this is the amount of lines + lines: u16, + /// last visible height + height: u16, +} + +impl ParagraphState { + pub const fn lines(self) -> u16 { + self.lines + } + + pub const fn height(self) -> u16 { + self.height + } + + pub const fn scroll(self) -> ScrollPos { + self.scroll + } + + pub fn set_scroll(&mut self, scroll: ScrollPos) { + self.scroll = scroll; + } +} + +impl<'a> StatefulParagraph<'a> { + pub fn new(text: T) -> Self + where + T: Into>, + { + Self { + block: None, + style: Style::default(), + wrap: None, + text: text.into(), + alignment: Alignment::Left, + } + } + + #[allow(clippy::missing_const_for_fn)] + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } + + pub const fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + + pub const fn wrap(mut self, wrap: Wrap) -> Self { + self.wrap = Some(wrap); + self + } + + pub const fn alignment(mut self, alignment: Alignment) -> Self { + self.alignment = alignment; + self + } +} + +impl<'a> StatefulWidget for StatefulParagraph<'a> { + type State = ParagraphState; + + fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + buf.set_style(area, self.style); + let text_area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + if text_area.height < 1 { + return; + } + + let style = self.style; + let mut styled = self.text.lines.iter().flat_map(|spans| { + spans + .0 + .iter() + .flat_map(|span| span.styled_graphemes(style)) + // Required given the way composers work but might be refactored out if we change + // composers to operate on lines instead of a stream of graphemes. + .chain(iter::once(StyledGrapheme { + symbol: "\n", + style: self.style, + })) + }); + + let mut line_composer: Box = if let Some(Wrap { trim }) = self.wrap { + Box::new(WordWrapper::new(&mut styled, text_area.width, trim)) + } else { + let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width)); + if let Alignment::Left = self.alignment { + line_composer.set_horizontal_offset(state.scroll.x); + } + line_composer + }; + let mut y = 0; + let mut end_reached = false; + while let Some((current_line, current_line_width)) = line_composer.next_line() { + if !end_reached && y >= state.scroll.y { + let mut x = get_line_offset(current_line_width, text_area.width, self.alignment); + for StyledGrapheme { symbol, style } in current_line { + buf.get_mut(text_area.left() + x, text_area.top() + y - state.scroll.y) + .set_symbol(if symbol.is_empty() { + // If the symbol is empty, the last char which rendered last time will + // leave on the line. It's a quick fix. + " " + } else { + symbol + }) + .set_style(*style); + x += Cast::::cast(symbol.width()); + } + } + y += 1; + if y >= text_area.height + state.scroll.y { + end_reached = true; + } + } + + state.lines = y; + state.height = area.height; + } +} diff --git a/src/ui/syntax_text.rs b/src/ui/syntax_text.rs new file mode 100644 index 0000000..235f65a --- /dev/null +++ b/src/ui/syntax_text.rs @@ -0,0 +1,106 @@ +use std::ops::Range; +use syntect::{ + highlighting::{ + FontStyle, HighlightState, Highlighter, RangedHighlightIterator, Style, ThemeSet, + }, + parsing::{ParseState, ScopeStack, SyntaxSet}, +}; +use tui::text::{Span, Spans}; + +struct SyntaxLine { + items: Vec<(Style, usize, Range)>, +} + +pub struct SyntaxText { + text: String, + lines: Vec, +} + +impl SyntaxText { + pub fn new(text: String) -> Self { + let syntax_set: SyntaxSet = SyntaxSet::load_defaults_nonewlines(); + let theme_set: ThemeSet = ThemeSet::load_defaults(); + + let mut state = ParseState::new(syntax_set.find_syntax_by_extension("sql").unwrap()); + let highlighter = Highlighter::new(&theme_set.themes["base16-eighties.dark"]); + let mut syntax_lines: Vec = Vec::new(); + let mut highlight_state = HighlightState::new(&highlighter, ScopeStack::new()); + + for (number, line) in text.lines().enumerate() { + let ops = state.parse_line(line, &syntax_set); + let iter = + RangedHighlightIterator::new(&mut highlight_state, &ops[..], line, &highlighter); + + syntax_lines.push(SyntaxLine { + items: iter + .map(|(style, _, range)| (style, number, range)) + .collect(), + }); + } + + Self { + text, + lines: syntax_lines, + } + } + + pub fn convert(&self) -> tui::text::Text<'_> { + let mut result_lines: Vec = Vec::with_capacity(self.lines.len()); + + for (syntax_line, line_content) in self.lines.iter().zip(self.text.lines()) { + let mut line_span = Spans(Vec::with_capacity(syntax_line.items.len())); + + for (style, _, range) in &syntax_line.items { + let item_content = &line_content[range.clone()]; + let item_style = syntact_style_to_tui(style); + + line_span.0.push(Span::styled(item_content, item_style)); + } + + result_lines.push(line_span); + } + + result_lines.into() + } +} + +impl<'a> From<&'a SyntaxText> for tui::text::Text<'a> { + fn from(v: &'a SyntaxText) -> Self { + let mut result_lines: Vec = Vec::with_capacity(v.lines.len()); + + for (syntax_line, line_content) in v.lines.iter().zip(v.text.lines()) { + let mut line_span = Spans(Vec::with_capacity(syntax_line.items.len())); + + for (style, _, range) in &syntax_line.items { + let item_content = &line_content[range.clone()]; + let item_style = syntact_style_to_tui(style); + + line_span.0.push(Span::styled(item_content, item_style)); + } + + result_lines.push(line_span); + } + + result_lines.into() + } +} + +fn syntact_style_to_tui(style: &Style) -> tui::style::Style { + let mut res = tui::style::Style::default().fg(tui::style::Color::Rgb( + style.foreground.r, + style.foreground.g, + style.foreground.b, + )); + + if style.font_style.contains(FontStyle::BOLD) { + res = res.add_modifier(tui::style::Modifier::BOLD); + } + if style.font_style.contains(FontStyle::ITALIC) { + res = res.add_modifier(tui::style::Modifier::ITALIC); + } + if style.font_style.contains(FontStyle::UNDERLINE) { + res = res.add_modifier(tui::style::Modifier::UNDERLINED); + } + + res +}