Compare commits

..

No commits in common. 'main' and 'v0.1.0-alpha.3' have entirely different histories.

@ -1,15 +0,0 @@
Thank you for making gobang better!
Here's a checklist for things that will be checked during review or continuous integration.
- \[ ] Added passing unit tests
- \[ ] `cargo test` passes locally. It takes much time.
- \[ ] Run `cargo fmt`
Delete this line and everything above before opening your PR.
---
*Please write a short comment explaining your change (or "none" for internal only changes)*
changelog:

@ -133,17 +133,11 @@ jobs:
needs: release
steps:
- uses: actions/checkout@v1
- name: Install cargo-workspaces
uses: actions-rs/install@v0.1
with:
crate: cargo-workspaces
- name: Cargo publish
run: |
git config --global user.email "runner@gha.local"
git config --global user.name "Github Action"
cargo workspaces publish \
--yes --force '*' --exact \
--no-git-commit --allow-dirty --skip-published --token ${{ secrets.CRATES_IO_TOKEN }} \
custom ${{ needs.release.outputs.gobang_version }}
--no-git-commit --allow-dirty --skip-published \
custom ${{ needs.create-release.outputs.gobang_version }}

239
Cargo.lock generated

@ -2,12 +2,6 @@
# 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"
@ -37,15 +31,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anyhow"
version = "1.0.41"
@ -107,30 +92,6 @@ 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"
@ -213,7 +174,7 @@ version = "2.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
dependencies = [
"ansi_term 0.11.0",
"ansi_term",
"atty",
"bitflags",
"strsim",
@ -231,15 +192,6 @@ 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"
@ -330,31 +282,15 @@ dependencies = [
"subtle",
]
[[package]]
name = "ctor"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc0a48a9b826acdf4028595adc9db92caea352f7af011a3034acd172a52a0aa"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "database-tree"
version = "0.1.0-alpha.5"
version = "0.1.0-alpha.3"
dependencies = [
"anyhow",
"chrono",
"thiserror",
]
[[package]]
name = "diff"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499"
[[package]]
name = "digest"
version = "0.9.0"
@ -423,34 +359,6 @@ 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"
@ -595,7 +503,7 @@ dependencies = [
[[package]]
name = "gobang"
version = "0.1.0-alpha.5"
version = "0.1.0-alpha.3"
dependencies = [
"anyhow",
"async-trait",
@ -607,7 +515,6 @@ dependencies = [
"easy-cast",
"futures",
"itertools",
"pretty_assertions",
"rust_decimal",
"serde",
"serde_json",
@ -615,11 +522,9 @@ dependencies = [
"structopt",
"strum",
"strum_macros",
"syntect",
"tokio",
"toml",
"tui",
"unicode-segmentation",
"unicode-width",
"which",
]
@ -739,12 +644,6 @@ 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"
@ -781,21 +680,6 @@ 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"
@ -843,16 +727,6 @@ 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"
@ -990,15 +864,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "output_vt100"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9"
dependencies = [
"winapi",
]
[[package]]
name = "parking_lot"
version = "0.11.1"
@ -1059,38 +924,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
[[package]]
name = "pretty_assertions"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0cfe1b2403f172ba0f234e500906ee0a3e493fb81092dac23ebefe129301cc"
dependencies = [
"ansi_term 0.12.1",
"ctor",
"diff",
"output_vt100",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
@ -1292,21 +1131,6 @@ 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"
@ -1656,28 +1480,6 @@ 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"
@ -1897,17 +1699,6 @@ 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"
@ -2034,15 +1825,6 @@ 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"
@ -2055,21 +1837,6 @@ 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"

@ -1,6 +1,6 @@
[package]
name = "gobang"
version = "0.1.0-alpha.5"
version = "0.1.0-alpha.3"
authors = ["Takayuki Maeda <takoyaki0316@gmail.com>"]
edition = "2018"
license = "MIT"
@ -29,7 +29,7 @@ serde = "1.0"
toml = "0.4"
strum = "0.21"
strum_macros = "0.21"
database-tree = { path = "./database-tree", version = "0.1.0-alpha.5" }
database-tree = { path = "./database-tree", version = "0.1.0-alpha.3" }
easy-cast = "0.4"
async-trait = "0.1.50"
itertools = "0.10.0"
@ -37,11 +37,6 @@ 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"
[dev-dependencies]
pretty_assertions = "1.0.0"

@ -20,65 +20,30 @@ A cross-platform TUI database management tool written in Rust
## TODOs
- [ ] SQL editor
- [ ] Query widget
- [ ] Custom key bindings
- [ ] Custom theme settings
- [ ] Support the other databases
## What does "gobang" come from?
gobang means a Japanese game played on goban, a go board. The appearance of goban looks like table structure. And I live in Kyoto, Japan. In Kyoto city, streets are laid out on a grid (We call it “goban no me no youna (碁盤の目のような)”). They are why I named this project "gobang".
## Installation
### With Homebrew (Linux, macOS)
### Homebrew (Linux/macOS)
If youre using Homebrew or Linuxbrew, install the gobang formula:
```
brew install tako8ki/tap/gobang
```
### On Windows
If you're a Windows Scoop user, then you can install gobang from the [official bucket](https://github.com/ScoopInstaller/Main/blob/master/bucket/gobang.json):
```
scoop install gobang
```
### On NixOS
If you're a Nix user, you can install [gobang](https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/database/gobang/default.nix) from nixpkgs:
```
$ nix-env --install gobang
$ brew install tako8ki/tap/gobang
```
### On Archlinux
If you're an Archlinux user, you can install [gobang](https://aur.archlinux.org/packages/gobang-bin) from AUR:
```
paru -S gobang-bin
```
### On NetBSD
If you're a NetBSD user, then you can install gobang from [pkgsrc](https://pkgsrc.se/databases/gobang):
```
pkgin install gobang
```
### With Cargo (Linux, macOS, Windows)
### Cargo (Linux/macOS/Windows)
If you already have a Rust environment set up, you can use the `cargo install` command:
```
cargo install --version 0.1.0-alpha.5 gobang
$ cargo install --version 0.1.0-alpha.3 gobang
```
### From binaries (Linux, macOS, Windows)
### Using a release binary (Linux/macOS/Windows)
- Download the [latest release binary](https://github.com/TaKO8Ki/gobang/releases) for your system
- Set the `PATH` environment variable
@ -90,7 +55,6 @@ $ gobang
```
```
$ gobang -h
USAGE:
gobang [OPTIONS]
@ -118,7 +82,6 @@ If you want to add connections, you need to edit your config file. For more info
| <kbd>/</kbd> | Filter |
| <kbd>?</kbd> | Help |
| <kbd>1</kbd>, <kbd>2</kbd>, <kbd>3</kbd>, <kbd>4</kbd>, <kbd>5</kbd> | Switch to records/columns/constraints/foreign keys/indexes tab |
| <kbd>Esc</kbd> | Hide pop up |
## Configuration
@ -144,7 +107,6 @@ host = "localhost"
port = 3306
password = "password"
database = "foo"
name = "mysql Foo DB"
[[conn]]
type = "postgres"
@ -152,13 +114,8 @@ user = "root"
host = "localhost"
port = 5432
database = "bar"
name = "postgres Bar DB"
[[conn]]
type = "sqlite"
path = "/path/to/baz.db"
```
## Contribution
Contributions, issues and pull requests are welcome!

@ -1,6 +1,6 @@
[package]
name = "database-tree"
version = "0.1.0-alpha.5"
version = "0.1.0-alpha.3"
authors = ["Takayuki Maeda <takoyaki0316@gmail.com>"]
edition = "2018"
license = "MIT"

@ -16,7 +16,6 @@ pub enum MoveSelection {
Right,
Top,
End,
Enter,
}
#[derive(Debug, Clone, Copy)]
@ -113,7 +112,6 @@ impl DatabaseTree {
MoveSelection::Right => self.selection_right(selection),
MoveSelection::Top => Self::selection_start(selection),
MoveSelection::End => self.selection_end(selection),
MoveSelection::Enter => self.expand(selection),
};
let changed_index = new_index.map(|i| i != selection).unwrap_or_default();
@ -317,22 +315,6 @@ impl DatabaseTree {
self.select_parent(current_index)
}
fn expand(&mut self, current_selection: usize) -> Option<usize> {
let item = &mut self.items.tree_items.get(current_selection)?;
if item.kind().is_database() && item.kind().is_database_collapsed() {
self.items.expand(current_selection, false);
return Some(current_selection);
}
if item.kind().is_schema() && item.kind().is_schema_collapsed() {
self.items.expand(current_selection, false);
return Some(current_selection);
}
None
}
fn selection_right(&mut self, current_selection: usize) -> Option<usize> {
let item = &mut self.items.tree_items.get(current_selection)?;
@ -433,62 +415,6 @@ mod test {
assert_eq!(tree.selection, Some(2));
}
#[test]
fn test_expand() {
let items = vec![Database::new(
"a".to_string(),
vec![Table::new("b".to_string()).into()],
)];
// a
// b
let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap();
assert!(tree.move_selection(MoveSelection::Enter));
assert!(!tree.items.tree_items[0].kind().is_database_collapsed());
assert_eq!(tree.selection, Some(0));
assert!(tree.move_selection(MoveSelection::Down));
assert_eq!(tree.selection, Some(1));
assert!(!tree.move_selection(MoveSelection::Enter));
assert_eq!(tree.selection, Some(1));
let items = vec![Database::new(
"a".to_string(),
vec![Schema {
name: "b".to_string(),
tables: vec![Table::new("c".to_string()).into()],
}
.into()],
)];
// a
// b
// c
let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap();
assert!(tree.move_selection(MoveSelection::Enter));
assert!(!tree.items.tree_items[0].kind().is_database_collapsed());
assert!(tree.items.tree_items[1].kind().is_schema_collapsed());
assert_eq!(tree.selection, Some(0));
assert!(tree.move_selection(MoveSelection::Down));
assert_eq!(tree.selection, Some(1));
assert!(tree.move_selection(MoveSelection::Enter));
assert!(!tree.items.tree_items[1].kind().is_database_collapsed());
assert_eq!(tree.selection, Some(1));
assert!(tree.move_selection(MoveSelection::Down));
assert_eq!(tree.selection, Some(2));
assert!(!tree.move_selection(MoveSelection::Enter));
assert_eq!(tree.selection, Some(2));
}
#[test]
fn test_selection_multiple_up_down() {
let items = vec![Database::new(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 18 MiB

@ -5,27 +5,12 @@ 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"
@ -37,24 +22,8 @@ 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]]
type = "sqlite"
path = "$HOME/Downloads/chinook.db"
path = "/Users/tako8ki/Downloads/chinook.db"

@ -1,17 +1,16 @@
use crate::clipboard::copy_to_clipboard;
use crate::components::{
CommandInfo, Component as _, DrawableComponent as _, EventState, StatefulDrawableComponent,
};
use crate::components::{CommandInfo, Component as _, DrawableComponent as _, EventState};
use crate::database::{MySqlPool, Pool, PostgresPool, SqlitePool, RECORDS_LIMIT_PER_PAGE};
use crate::event::Key;
use crate::{
components::tab::Tab,
components::{
command, ConnectionsComponent, DatabasesComponent, ErrorComponent, HelpComponent,
PropertiesComponent, RecordTableComponent, SqlEditorComponent, TabComponent,
RecordTableComponent, TabComponent, TableComponent,
},
config::Config,
};
use database_tree::Database;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
@ -25,15 +24,16 @@ pub enum Focus {
}
pub struct App {
record_table: RecordTableComponent,
properties: PropertiesComponent,
sql_editor: SqlEditorComponent,
column_table: TableComponent,
constraint_table: TableComponent,
foreign_key_table: TableComponent,
index_table: TableComponent,
focus: Focus,
tab: TabComponent,
help: HelpComponent,
databases: DatabasesComponent,
connections: ConnectionsComponent,
pool: Option<Box<dyn Pool>>,
left_main_chunk_percentage: u16,
pub config: Config,
pub error: ErrorComponent,
}
@ -44,15 +44,16 @@ impl App {
config: config.clone(),
connections: ConnectionsComponent::new(config.key_config.clone(), config.conn),
record_table: RecordTableComponent::new(config.key_config.clone()),
properties: PropertiesComponent::new(config.key_config.clone()),
sql_editor: SqlEditorComponent::new(config.key_config.clone()),
column_table: TableComponent::new(config.key_config.clone()),
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()),
tab: TabComponent::new(config.key_config.clone()),
help: HelpComponent::new(config.key_config.clone()),
databases: DatabasesComponent::new(config.key_config.clone()),
error: ErrorComponent::new(config.key_config),
focus: Focus::ConnectionList,
pool: None,
left_main_chunk_percentage: 15,
}
}
@ -72,14 +73,12 @@ impl App {
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(self.left_main_chunk_percentage),
Constraint::Percentage((100_u16).saturating_sub(self.left_main_chunk_percentage)),
])
.constraints([Constraint::Percentage(15), Constraint::Percentage(85)])
.split(f.size());
self.databases
.draw(f, main_chunks[0], matches!(self.focus, Focus::DabataseList))?;
.draw(f, main_chunks[0], matches!(self.focus, Focus::DabataseList))
.unwrap();
let right_chunks = Layout::default()
.direction(Direction::Vertical)
@ -93,13 +92,23 @@ impl App {
self.record_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))?;
Tab::Columns => {
self.column_table
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?
}
Tab::Properties => {
self.properties
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?;
Tab::Constraints => self.constraint_table.draw(
f,
right_chunks[1],
matches!(self.focus, Focus::Table),
)?,
Tab::ForeignKeys => self.foreign_key_table.draw(
f,
right_chunks[1],
matches!(self.focus, Focus::Table),
)?,
Tab::Indexes => {
self.index_table
.draw(f, right_chunks[1], matches!(self.focus, Focus::Table))?
}
}
self.error.draw(f, Rect::default(), false)?;
@ -113,24 +122,19 @@ impl App {
fn commands(&self) -> Vec<CommandInfo> {
let mut res = vec![
CommandInfo::new(command::exit_pop_up(&self.config.key_config)),
CommandInfo::new(command::filter(&self.config.key_config)),
CommandInfo::new(command::help(&self.config.key_config)),
CommandInfo::new(command::toggle_tabs(&self.config.key_config)),
CommandInfo::new(command::scroll(&self.config.key_config)),
CommandInfo::new(command::scroll_to_top_bottom(&self.config.key_config)),
CommandInfo::new(command::scroll_up_down_multiple_lines(
&self.config.key_config,
)),
CommandInfo::new(command::move_focus(&self.config.key_config)),
CommandInfo::new(command::extend_or_shorten_widget_width(
&self.config.key_config,
)),
CommandInfo::new(command::filter(&self.config.key_config)),
CommandInfo::new(command::help(&self.config.key_config)),
CommandInfo::new(command::toggle_tabs(&self.config.key_config)),
];
self.databases.commands(&mut res);
self.record_table.commands(&mut res);
self.properties.commands(&mut res);
res
}
@ -153,9 +157,18 @@ impl App {
SqlitePool::new(conn.database_url()?.as_str()).await?,
))
};
self.databases
.update(conn, self.pool.as_ref().unwrap())
.await?;
let databases = match &conn.database {
Some(database) => vec![Database::new(
database.clone(),
self.pool
.as_ref()
.unwrap()
.get_tables(database.clone())
.await?,
)],
None => self.pool.as_ref().unwrap().get_databases().await?,
};
self.databases.update(databases.as_slice()).unwrap();
self.focus = Focus::DabataseList;
self.record_table.reset();
self.tab.reset();
@ -163,6 +176,95 @@ impl App {
Ok(())
}
async fn update_table(&mut self) -> anyhow::Result<()> {
if let Some((database, table)) = self.databases.tree().selected_table() {
self.focus = Focus::Table;
self.record_table.reset();
let (headers, records) = self
.pool
.as_ref()
.unwrap()
.get_records(&database, &table, 0, None)
.await?;
self.record_table
.update(records, headers, database.clone(), table.clone());
self.column_table.reset();
let columns = self
.pool
.as_ref()
.unwrap()
.get_columns(&database, &table)
.await?;
if !columns.is_empty() {
self.column_table.update(
columns
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
columns.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.constraint_table.reset();
let constraints = self
.pool
.as_ref()
.unwrap()
.get_constraints(&database, &table)
.await?;
if !constraints.is_empty() {
self.constraint_table.update(
constraints
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
constraints.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.foreign_key_table.reset();
let foreign_keys = self
.pool
.as_ref()
.unwrap()
.get_foreign_keys(&database, &table)
.await?;
if !foreign_keys.is_empty() {
self.foreign_key_table.update(
foreign_keys
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
foreign_keys.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.index_table.reset();
let indexes = self
.pool
.as_ref()
.unwrap()
.get_indexes(&database, &table)
.await?;
if !indexes.is_empty() {
self.index_table.update(
indexes
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
indexes.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
}
Ok(())
}
async fn update_record_table(&mut self) -> anyhow::Result<()> {
if let Some((database, table)) = self.databases.tree().selected_table() {
let (headers, records) = self
@ -173,7 +275,7 @@ impl App {
&database,
&table,
0,
if self.record_table.filter.input_str().is_empty() {
if self.record_table.filter.input.is_empty() {
None
} else {
Some(self.record_table.filter.input_str())
@ -199,7 +301,7 @@ impl App {
Ok(EventState::NotConsumed)
}
async fn components_event(&mut self, key: Key) -> anyhow::Result<EventState> {
pub async fn components_event(&mut self, key: Key) -> anyhow::Result<EventState> {
if self.error.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
}
@ -225,21 +327,7 @@ impl App {
}
if key == self.config.key_config.enter && self.databases.tree_focused() {
if let Some((database, table)) = self.databases.tree().selected_table() {
self.record_table.reset();
let (headers, records) = self
.pool
.as_ref()
.unwrap()
.get_records(&database, &table, 0, None)
.await?;
self.record_table
.update(records, headers, database.clone(), table.clone());
self.properties
.update(database.clone(), table.clone(), self.pool.as_ref().unwrap())
.await?;
self.focus = Focus::Table;
}
self.update_table().await?;
return Ok(EventState::Consumed);
}
}
@ -279,7 +367,7 @@ impl App {
&database,
&table,
index as u16,
if self.record_table.filter.input_str().is_empty() {
if self.record_table.filter.input.is_empty() {
None
} else {
Some(self.record_table.filter.input_str())
@ -295,56 +383,57 @@ 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()
{
Tab::Columns => {
if self.column_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
if key == self.config.key_config.copy {
if let Some(text) = self.column_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
Tab::Properties => {
if self.properties.event(key)?.is_consumed() {
Tab::Constraints => {
if self.constraint_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
}
};
}
}
if self.extend_or_shorten_widget_width(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
if key == self.config.key_config.copy {
if let Some(text) = self.constraint_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
Tab::ForeignKeys => {
if self.foreign_key_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
Ok(EventState::NotConsumed)
}
if key == self.config.key_config.copy {
if let Some(text) = self.foreign_key_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
Tab::Indexes => {
if self.index_table.event(key)?.is_consumed() {
return Ok(EventState::Consumed);
};
fn extend_or_shorten_widget_width(&mut self, key: Key) -> anyhow::Result<EventState> {
if key
== self
.config
.key_config
.extend_or_shorten_widget_width_to_left
{
self.left_main_chunk_percentage =
self.left_main_chunk_percentage.saturating_sub(5).max(15);
return Ok(EventState::Consumed);
} else if key
== self
.config
.key_config
.extend_or_shorten_widget_width_to_right
{
self.left_main_chunk_percentage = (self.left_main_chunk_percentage + 5).min(70);
return Ok(EventState::Consumed);
if key == self.config.key_config.copy {
if let Some(text) = self.index_table.selected_cells() {
copy_to_clipboard(text.as_str())?
}
};
}
};
}
}
Ok(EventState::NotConsumed)
}
fn move_focus(&mut self, key: Key) -> anyhow::Result<EventState> {
pub fn move_focus(&mut self, key: Key) -> anyhow::Result<EventState> {
if key == self.config.key_config.focus_connections {
self.focus = Focus::ConnectionList;
return Ok(EventState::Consumed);
@ -375,38 +464,3 @@ impl App {
Ok(EventState::NotConsumed)
}
}
#[cfg(test)]
mod test {
use super::{App, Config, EventState, Key};
#[test]
fn test_extend_or_shorten_widget_width() {
let mut app = App::new(Config::default());
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('>')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 20);
app.left_main_chunk_percentage = 70;
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('>')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 70);
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('<')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 65);
app.left_main_chunk_percentage = 15;
assert_eq!(
app.extend_or_shorten_widget_width(Key::Char('<')).unwrap(),
EventState::Consumed
);
assert_eq!(app.left_main_chunk_percentage, 15);
}
}

@ -3,7 +3,6 @@ use crate::config::KeyConfig;
static CMD_GROUP_GENERAL: &str = "-- General --";
static CMD_GROUP_TABLE: &str = "-- Table --";
static CMD_GROUP_DATABASES: &str = "-- Databases --";
static CMD_GROUP_PROPERTIES: &str = "-- Properties --";
#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)]
pub struct CommandText {
@ -96,16 +95,6 @@ pub fn extend_selection_by_one_cell(key: &KeyConfig) -> CommandText {
)
}
pub fn extend_or_shorten_widget_width(key: &KeyConfig) -> CommandText {
CommandText::new(
format!(
"Extend/shorten widget width to left/right [{},{}]",
key.extend_or_shorten_widget_width_to_left, key.extend_or_shorten_widget_width_to_right
),
CMD_GROUP_GENERAL,
)
}
pub fn tab_records(key: &KeyConfig) -> CommandText {
CommandText::new(format!("Records [{}]", key.tab_records), CMD_GROUP_TABLE)
}
@ -132,37 +121,17 @@ 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 tab_properties(key: &KeyConfig) -> CommandText {
CommandText::new(
format!("Properties [{}]", key.tab_properties),
CMD_GROUP_TABLE,
)
}
pub fn toggle_tabs(key_config: &KeyConfig) -> CommandText {
CommandText::new(
format!(
"Tab [{},{},{}]",
key_config.tab_records, key_config.tab_properties, key_config.tab_sql_editor
),
CMD_GROUP_GENERAL,
)
}
pub fn toggle_property_tabs(key_config: &KeyConfig) -> CommandText {
CommandText::new(
format!(
"Tab [{},{},{},{}]",
"Tab [{},{},{},{},{}]",
key_config.tab_records,
key_config.tab_columns,
key_config.tab_constraints,
key_config.tab_foreign_keys,
key_config.tab_indexes
),
CMD_GROUP_PROPERTIES,
CMD_GROUP_GENERAL,
)
}
@ -172,10 +141,3 @@ pub fn help(key_config: &KeyConfig) -> CommandText {
CMD_GROUP_GENERAL,
)
}
pub fn exit_pop_up(key_config: &KeyConfig) -> CommandText {
CommandText::new(
format!("Exit pop up [{}]", key_config.exit_popup),
CMD_GROUP_GENERAL,
)
}

@ -1,191 +0,0 @@
use super::{Component, EventState, MovableComponent};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use anyhow::Result;
use tui::{
backend::Backend,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Clear, List, ListItem, ListState},
Frame,
};
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,
state: ListState,
word: String,
candidates: Vec<String>,
}
impl CompletionComponent {
pub fn new(key_config: KeyConfig, word: impl Into<String>, all: bool) -> Self {
Self {
key_config,
state: ListState::default(),
word: word.into(),
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()
},
}
}
pub fn update(&mut self, word: impl Into<String>) {
self.word = word.into();
self.state.select(None);
self.state.select(Some(0))
}
fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.filterd_candidates().count() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.filterd_candidates().count() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
fn filterd_candidates(&self) -> impl Iterator<Item = &String> {
self.candidates.iter().filter(move |c| {
(c.starts_with(self.word.to_lowercase().as_str())
|| c.starts_with(self.word.to_uppercase().as_str()))
&& !self.word.is_empty()
})
}
pub fn selected_candidate(&self) -> Option<String> {
self.filterd_candidates()
.collect::<Vec<&String>>()
.get(self.state.selected()?)
.map(|c| c.to_string())
}
pub fn word(&self) -> String {
self.word.to_string()
}
}
impl MovableComponent for CompletionComponent {
fn draw<B: Backend>(
&mut self,
f: &mut Frame<B>,
area: Rect,
_focused: bool,
x: u16,
y: u16,
) -> Result<()> {
if !self.word.is_empty() {
let width = 30;
let candidates = self
.filterd_candidates()
.map(|c| ListItem::new(c.to_string()))
.collect::<Vec<ListItem>>();
if candidates.clone().is_empty() {
return Ok(());
}
let candidate_list = List::new(candidates.clone())
.block(Block::default().borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::Blue))
.style(Style::default());
let area = Rect::new(
area.x + x,
area.y + y + 2,
width
.min(f.size().width)
.min(f.size().right().saturating_sub(area.x + x)),
(candidates.len().min(5) as u16 + 2)
.min(f.size().bottom().saturating_sub(area.y + y + 2)),
);
f.render_widget(Clear, area);
f.render_stateful_widget(candidate_list, area, &mut self.state);
}
Ok(())
}
}
impl Component for CompletionComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
if key == self.key_config.move_down {
self.next();
return Ok(EventState::Consumed);
} else if key == self.key_config.move_up {
self.previous();
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
#[cfg(test)]
mod test {
use super::{CompletionComponent, KeyConfig};
#[test]
fn test_filterd_candidates_lowercase() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "an", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"AND".to_string()]
);
}
#[test]
fn test_filterd_candidates_uppercase() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "AN", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"AND".to_string()]
);
}
#[test]
fn test_filterd_candidates_multiple_candidates() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "n", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"NOT".to_string(), &"NULL".to_string()]
);
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "N", false)
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"NOT".to_string(), &"NULL".to_string()]
);
}
}

@ -1,4 +1,4 @@
use super::{Component, EventState, StatefulDrawableComponent};
use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::{Connection, KeyConfig};
use crate::event::Key;
@ -81,7 +81,7 @@ impl ConnectionsComponent {
}
}
impl StatefulDrawableComponent for ConnectionsComponent {
impl DrawableComponent for ConnectionsComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
let width = 80;
let height = 20;
@ -89,11 +89,11 @@ impl StatefulDrawableComponent for ConnectionsComponent {
let mut connections: Vec<ListItem> = Vec::new();
for c in conns {
connections.push(
ListItem::new(vec![Spans::from(Span::raw(c.database_url_with_name()?))])
ListItem::new(vec![Spans::from(Span::raw(c.database_url()?))])
.style(Style::default()),
)
}
let connections = List::new(connections)
let tasks = List::new(connections)
.block(Block::default().borders(Borders::ALL).title("Connections"))
.highlight_style(Style::default().bg(Color::Blue))
.style(Style::default());
@ -104,9 +104,8 @@ impl StatefulDrawableComponent for ConnectionsComponent {
width.min(f.size().width),
height.min(f.size().height),
);
f.render_widget(Clear, area);
f.render_stateful_widget(connections, area, &mut self.state);
f.render_stateful_widget(tasks, area, &mut self.state);
Ok(())
}
}

@ -1,132 +0,0 @@
use super::{compute_character_width, Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::event::Key;
use anyhow::Result;
use database_tree::Table;
use tui::{
backend::Backend,
layout::Rect,
style::{Color, Style},
text::Spans,
widgets::{Block, Borders, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
pub struct DatabaseFilterComponent {
pub table: Option<Table>,
input: Vec<char>,
input_idx: usize,
input_cursor_position: u16,
}
impl DatabaseFilterComponent {
pub fn new() -> Self {
Self {
table: None,
input: Vec::new(),
input_idx: 0,
input_cursor_position: 0,
}
}
pub fn input_str(&self) -> String {
self.input.iter().collect()
}
pub fn reset(&mut self) {
self.table = None;
self.input = Vec::new();
self.input_idx = 0;
self.input_cursor_position = 0;
}
}
impl DrawableComponent for DatabaseFilterComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let query = Paragraph::new(Spans::from(format!(
"{:w$}",
if self.input.is_empty() && !focused {
"Filter tables".to_string()
} else {
self.input_str()
},
w = area.width as usize
)))
.style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
})
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(query, area);
if focused {
f.set_cursor(
(area.x + self.input_cursor_position).min(area.right().saturating_sub(1)),
area.y,
)
}
Ok(())
}
}
impl Component for DatabaseFilterComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
match key {
Key::Char(c) => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position += compute_character_width(c);
return Ok(EventState::Consumed);
}
Key::Delete | Key::Backspace => {
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 -= compute_character_width(last_c);
}
return Ok(EventState::Consumed);
}
Key::Left => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx -= 1;
self.input_cursor_position = self
.input_cursor_position
.saturating_sub(compute_character_width(self.input[self.input_idx]));
}
return Ok(EventState::Consumed);
}
Key::Ctrl('a') => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx = 0;
self.input_cursor_position = 0
}
return Ok(EventState::Consumed);
}
Key::Right => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position += compute_character_width(next_c);
}
return Ok(EventState::Consumed);
}
Key::Ctrl('e') => {
if self.input_idx < self.input.len() {
self.input_idx = self.input.len();
self.input_cursor_position = self.input_str().width() as u16;
}
return Ok(EventState::Consumed);
}
_ => (),
}
Ok(EventState::NotConsumed)
}
}

@ -1,10 +1,9 @@
use super::{
utils::scroll_vertical::VerticalScroll, Component, DatabaseFilterComponent, DrawableComponent,
compute_character_width, utils::scroll_vertical::VerticalScroll, Component, DrawableComponent,
EventState,
};
use crate::components::command::{self, CommandInfo};
use crate::config::{Connection, KeyConfig};
use crate::database::Pool;
use crate::config::KeyConfig;
use crate::event::Key;
use crate::ui::common_nav;
use crate::ui::scrolllist::draw_list_block;
@ -17,9 +16,10 @@ use tui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Span, Spans},
widgets::{Block, Borders},
widgets::{Block, Borders, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
// ▸
const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}";
@ -35,9 +35,11 @@ pub enum Focus {
pub struct DatabasesComponent {
tree: DatabaseTree,
filter: DatabaseFilterComponent,
filterd_tree: Option<DatabaseTree>,
scroll: VerticalScroll,
input: Vec<char>,
input_idx: usize,
input_cursor_position: u16,
focus: Focus,
key_config: KeyConfig,
}
@ -46,25 +48,26 @@ impl DatabasesComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
tree: DatabaseTree::default(),
filter: DatabaseFilterComponent::new(),
filterd_tree: None,
scroll: VerticalScroll::new(false, false),
input: Vec::new(),
input_idx: 0,
input_cursor_position: 0,
focus: Focus::Tree,
key_config,
}
}
pub async fn update(&mut self, connection: &Connection, pool: &Box<dyn Pool>) -> Result<()> {
let databases = match &connection.database {
Some(database) => vec![Database::new(
database.clone(),
pool.get_tables(database.clone()).await?,
)],
None => pool.get_databases().await?,
};
self.tree = DatabaseTree::new(databases.as_slice(), &BTreeSet::new())?;
fn input_str(&self) -> String {
self.input.iter().collect()
}
pub fn update(&mut self, list: &[Database]) -> Result<()> {
self.tree = DatabaseTree::new(list, &BTreeSet::new())?;
self.filterd_tree = None;
self.filter.reset();
self.input = Vec::new();
self.input_idx = 0;
self.input_cursor_position = 0;
Ok(())
}
@ -144,7 +147,7 @@ impl DatabasesComponent {
))
}
fn draw_tree<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw_tree<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) {
f.render_widget(
Block::default()
.title("Databases")
@ -164,8 +167,24 @@ impl DatabasesComponent {
.constraints([Constraint::Length(2), Constraint::Min(1)].as_ref())
.split(area);
self.filter
.draw(f, chunks[0], matches!(self.focus, Focus::Filter))?;
let filter = Paragraph::new(Span::styled(
format!(
"{}{:w$}",
if self.input.is_empty() && matches!(self.focus, Focus::Tree) {
"Filter tables".to_string()
} else {
self.input_str()
},
w = area.width as usize
),
if let Focus::Filter = self.focus {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
},
))
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(filter, chunks[0]);
let tree_height = chunks[1].height as usize;
let tree = if let Some(tree) = self.filterd_tree.as_ref() {
@ -190,10 +209,10 @@ impl DatabasesComponent {
item.clone(),
selected,
area.width,
if self.filter.input_str().is_empty() {
if self.input.is_empty() {
None
} else {
Some(self.filter.input_str())
Some(self.input_str())
},
)
});
@ -201,18 +220,20 @@ impl DatabasesComponent {
draw_list_block(f, chunks[1], Block::default().borders(Borders::NONE), items);
self.scroll.draw(f, chunks[1]);
Ok(())
if let Focus::Filter = self.focus {
f.set_cursor(area.x + self.input_cursor_position + 1, area.y + 1)
}
}
}
impl DrawableComponent for DatabasesComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)].as_ref())
.split(area);
self.draw_tree(f, chunks[0], focused)?;
self.draw_tree(f, chunks[0], focused);
Ok(())
}
}
@ -223,29 +244,70 @@ impl Component for DatabasesComponent {
}
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
if key == self.key_config.filter && self.focus == Focus::Tree {
self.focus = Focus::Filter;
return Ok(EventState::Consumed);
}
if matches!(self.focus, Focus::Filter) {
self.filterd_tree = if self.filter.input_str().is_empty() {
None
} else {
Some(self.tree.filter(self.filter.input_str()))
};
}
match key {
Key::Enter if matches!(self.focus, Focus::Filter) => {
self.focus = Focus::Tree;
Key::Char(c) if self.focus == Focus::Filter => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position += compute_character_width(c);
self.filterd_tree = Some(self.tree.filter(self.input_str()));
return Ok(EventState::Consumed);
}
key if matches!(self.focus, Focus::Filter) => {
if self.filter.event(key)?.is_consumed() {
Key::Delete | Key::Backspace if matches!(self.focus, Focus::Filter) => {
if input_str.width() > 0 {
if !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 -= compute_character_width(last_c);
}
self.filterd_tree = if self.input.is_empty() {
None
} else {
Some(self.tree.filter(self.input_str()))
};
return Ok(EventState::Consumed);
}
}
Key::Left if matches!(self.focus, Focus::Filter) => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx -= 1;
self.input_cursor_position = self
.input_cursor_position
.saturating_sub(compute_character_width(self.input[self.input_idx]));
}
return Ok(EventState::Consumed);
}
Key::Ctrl('a') => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx = 0;
self.input_cursor_position = 0
}
return Ok(EventState::Consumed);
}
Key::Right if matches!(self.focus, Focus::Filter) => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position += compute_character_width(next_c);
}
return Ok(EventState::Consumed);
}
Key::Ctrl('e') => {
if self.input_idx < self.input.len() {
self.input_idx = self.input.len();
self.input_cursor_position = self.input_str().width() as u16;
}
return Ok(EventState::Consumed);
}
Key::Enter if matches!(self.focus, Focus::Filter) => {
self.focus = Focus::Tree;
return Ok(EventState::Consumed);
}
key => {
if tree_nav(
if let Some(tree) = self.filterd_tree.as_mut() {

@ -1,66 +0,0 @@
use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use anyhow::Result;
use tui::{
backend::Backend,
layout::{Alignment, Rect},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
pub struct DebugComponent {
msg: String,
visible: bool,
key_config: KeyConfig,
}
impl DebugComponent {
#[allow(dead_code)]
pub fn new(key_config: KeyConfig, msg: String) -> Self {
Self {
msg,
visible: false,
key_config,
}
}
}
impl DrawableComponent for DebugComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
if true {
let width = 65;
let height = 10;
let error = Paragraph::new(self.msg.to_string())
.block(Block::default().title("Debug").borders(Borders::ALL))
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
let area = Rect::new(
(f.size().width.saturating_sub(width)) / 2,
(f.size().height.saturating_sub(height)) / 2,
width.min(f.size().width),
height.min(f.size().height),
);
f.render_widget(Clear, area);
f.render_widget(error, area);
}
Ok(())
}
}
impl Component for DebugComponent {
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
if self.visible {
if key == self.key_config.exit_popup {
self.msg = String::new();
self.hide();
return Ok(EventState::Consumed);
}
return Ok(EventState::NotConsumed);
}
Ok(EventState::NotConsumed)
}
}

@ -35,7 +35,7 @@ impl ErrorComponent {
}
impl DrawableComponent for ErrorComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
if self.visible {
let width = 65;
let height = 10;

@ -23,7 +23,7 @@ pub struct HelpComponent {
}
impl DrawableComponent for HelpComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, _area: Rect, _focused: bool) -> Result<()> {
if self.visible {
const SIZE: (u16, u16) = (65, 24);
let scroll_threshold = SIZE.1 / 3;

@ -1,13 +1,9 @@
pub mod command;
pub mod completion;
pub mod connections;
pub mod database_filter;
pub mod databases;
pub mod error;
pub mod help;
pub mod properties;
pub mod record_table;
pub mod sql_editor;
pub mod tab;
pub mod table;
pub mod table_filter;
@ -15,36 +11,25 @@ pub mod table_status;
pub mod table_value;
pub mod utils;
#[cfg(debug_assertions)]
pub mod debug;
pub use command::{CommandInfo, CommandText};
pub use completion::CompletionComponent;
pub use connections::ConnectionsComponent;
pub use database_filter::DatabaseFilterComponent;
pub use databases::DatabasesComponent;
pub use error::ErrorComponent;
pub use help::HelpComponent;
pub use properties::PropertiesComponent;
pub use record_table::RecordTableComponent;
pub use sql_editor::SqlEditorComponent;
pub use tab::TabComponent;
pub use table::TableComponent;
pub use table_filter::TableFilterComponent;
pub use table_status::TableStatusComponent;
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;
use tui::{backend::Backend, layout::Rect, Frame};
use unicode_width::UnicodeWidthChar;
#[derive(PartialEq, Debug)]
#[derive(PartialEq)]
pub enum EventState {
Consumed,
NotConsumed,
@ -67,24 +52,9 @@ impl From<bool> for EventState {
}
pub trait DrawableComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, rect: Rect, focused: bool) -> Result<()>;
}
pub trait StatefulDrawableComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, rect: Rect, focused: bool) -> Result<()>;
}
pub trait MovableComponent {
fn draw<B: Backend>(
&mut self,
f: &mut Frame<B>,
rect: Rect,
focused: bool,
x: u16,
y: u16,
) -> Result<()>;
}
/// base component trait
#[async_trait]
pub trait Component {
@ -92,14 +62,6 @@ pub trait Component {
fn event(&mut self, key: crate::event::Key) -> Result<EventState>;
async fn async_event(
&mut self,
_key: crate::event::Key,
_pool: &Box<dyn Pool>,
) -> Result<EventState> {
Ok(EventState::NotConsumed)
}
fn focused(&self) -> bool {
false
}

@ -1,200 +0,0 @@
use super::{Component, EventState, StatefulDrawableComponent};
use crate::clipboard::copy_to_clipboard;
use crate::components::command::{self, CommandInfo};
use crate::components::TableComponent;
use crate::config::KeyConfig;
use crate::database::Pool;
use crate::event::Key;
use anyhow::Result;
use async_trait::async_trait;
use database_tree::{Database, Table};
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, List, ListItem},
Frame,
};
#[derive(Debug, PartialEq)]
pub enum Focus {
Column,
Constraint,
ForeignKey,
Index,
}
impl std::fmt::Display for Focus {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
pub struct PropertiesComponent {
column_table: TableComponent,
constraint_table: TableComponent,
foreign_key_table: TableComponent,
index_table: TableComponent,
focus: Focus,
key_config: KeyConfig,
}
impl PropertiesComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
column_table: TableComponent::new(key_config.clone()),
constraint_table: TableComponent::new(key_config.clone()),
foreign_key_table: TableComponent::new(key_config.clone()),
index_table: TableComponent::new(key_config.clone()),
focus: Focus::Column,
key_config,
}
}
fn focused_component(&mut self) -> &mut TableComponent {
match self.focus {
Focus::Column => &mut self.column_table,
Focus::Constraint => &mut self.constraint_table,
Focus::ForeignKey => &mut self.foreign_key_table,
Focus::Index => &mut self.index_table,
}
}
pub async fn update(
&mut self,
database: Database,
table: Table,
pool: &Box<dyn Pool>,
) -> Result<()> {
self.column_table.reset();
let columns = pool.get_columns(&database, &table).await?;
if !columns.is_empty() {
self.column_table.update(
columns
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
columns.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.constraint_table.reset();
let constraints = pool.get_constraints(&database, &table).await?;
if !constraints.is_empty() {
self.constraint_table.update(
constraints
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
constraints.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.foreign_key_table.reset();
let foreign_keys = pool.get_foreign_keys(&database, &table).await?;
if !foreign_keys.is_empty() {
self.foreign_key_table.update(
foreign_keys
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
foreign_keys.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
self.index_table.reset();
let indexes = pool.get_indexes(&database, &table).await?;
if !indexes.is_empty() {
self.index_table.update(
indexes
.iter()
.map(|c| c.columns())
.collect::<Vec<Vec<String>>>(),
indexes.get(0).unwrap().fields(),
database.clone(),
table.clone(),
);
}
Ok(())
}
fn tab_names(&self) -> Vec<(Focus, String)> {
vec![
(Focus::Column, command::tab_columns(&self.key_config).name),
(
Focus::Constraint,
command::tab_constraints(&self.key_config).name,
),
(
Focus::ForeignKey,
command::tab_foreign_keys(&self.key_config).name,
),
(Focus::Index, command::tab_indexes(&self.key_config).name),
]
}
}
impl StatefulDrawableComponent for PropertiesComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Length(20), Constraint::Min(1)])
.split(area);
let tab_names = self
.tab_names()
.iter()
.map(|(f, c)| {
ListItem::new(c.to_string()).style(if *f == self.focus {
Style::default().bg(Color::Blue)
} else {
Style::default()
})
})
.collect::<Vec<ListItem>>();
let tab_list = List::new(tab_names)
.block(Block::default().borders(Borders::ALL).style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
}))
.style(Style::default());
f.render_widget(tab_list, layout[0]);
self.focused_component().draw(f, layout[1], focused)?;
Ok(())
}
}
#[async_trait]
impl Component for PropertiesComponent {
fn commands(&self, out: &mut Vec<CommandInfo>) {
out.push(CommandInfo::new(command::toggle_property_tabs(
&self.key_config,
)));
}
fn event(&mut self, key: Key) -> Result<EventState> {
self.focused_component().event(key)?;
if key == self.key_config.copy {
if let Some(text) = self.focused_component().selected_cells() {
copy_to_clipboard(text.as_str())?
}
} else if key == self.key_config.tab_columns {
self.focus = Focus::Column;
} else if key == self.key_config.tab_constraints {
self.focus = Focus::Constraint;
} else if key == self.key_config.tab_foreign_keys {
self.focus = Focus::ForeignKey;
} else if key == self.key_config.tab_indexes {
self.focus = Focus::Index;
}
Ok(EventState::NotConsumed)
}
}

@ -1,4 +1,4 @@
use super::{Component, EventState, StatefulDrawableComponent};
use super::{Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::components::{TableComponent, TableFilterComponent};
use crate::config::KeyConfig;
@ -26,7 +26,7 @@ pub struct RecordTableComponent {
impl RecordTableComponent {
pub fn new(key_config: KeyConfig) -> Self {
Self {
filter: TableFilterComponent::new(key_config.clone()),
filter: TableFilterComponent::default(),
table: TableComponent::new(key_config.clone()),
focus: Focus::Table,
key_config,
@ -54,18 +54,18 @@ impl RecordTableComponent {
}
}
impl StatefulDrawableComponent for RecordTableComponent {
impl DrawableComponent for RecordTableComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(3), Constraint::Length(5)])
.split(area);
self.table
.draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?;
self.filter
.draw(f, layout[0], focused && matches!(self.focus, Focus::Filter))?;
self.table
.draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?;
Ok(())
}
}

@ -1,285 +0,0 @@
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,
}
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<char>,
input_cursor_position_x: u16,
input_idx: usize,
table: TableComponent,
query_result: Option<QueryResult>,
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::<String>()
.split(' ')
.map(|i| i.to_string())
.collect::<Vec<String>>();
self.completion
.update(input.last().unwrap_or(&String::new()));
}
fn complete(&mut self) -> anyhow::Result<EventState> {
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::<Vec<String>>();
let last = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i >= &self.input_idx)
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
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::<Vec<String>>()
} else {
let mut c = candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>();
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::<u16>();
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::<u16>();
self.update_completion();
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
impl StatefulDrawableComponent for SqlEditorComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, 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::<String>())
.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<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
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<dyn Pool>) -> Result<EventState> {
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 })
}
}
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}

@ -16,8 +16,10 @@ use tui::{
#[derive(Debug, Clone, Copy, EnumIter)]
pub enum Tab {
Records,
Properties,
Sql,
Columns,
Constraints,
ForeignKeys,
Indexes,
}
impl std::fmt::Display for Tab {
@ -46,14 +48,16 @@ impl TabComponent {
fn names(&self) -> Vec<String> {
vec![
command::tab_records(&self.key_config).name,
command::tab_properties(&self.key_config).name,
command::tab_sql_editor(&self.key_config).name,
command::tab_columns(&self.key_config).name,
command::tab_constraints(&self.key_config).name,
command::tab_foreign_keys(&self.key_config).name,
command::tab_indexes(&self.key_config).name,
]
}
}
impl DrawableComponent for TabComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, _focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, _focused: bool) -> Result<()> {
let titles = self.names().iter().cloned().map(Spans::from).collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL))
@ -76,11 +80,17 @@ impl Component for TabComponent {
if key == self.key_config.tab_records {
self.selected_tab = Tab::Records;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_sql_editor {
self.selected_tab = Tab::Sql;
} else if key == self.key_config.tab_columns {
self.selected_tab = Tab::Columns;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_properties {
self.selected_tab = Tab::Properties;
} else if key == self.key_config.tab_constraints {
self.selected_tab = Tab::Constraints;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_foreign_keys {
self.selected_tab = Tab::ForeignKeys;
return Ok(EventState::Consumed);
} else if key == self.key_config.tab_indexes {
self.selected_tab = Tab::Indexes;
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)

@ -1,6 +1,6 @@
use super::{
utils::scroll_vertical::VerticalScroll, Component, DrawableComponent, EventState,
StatefulDrawableComponent, TableStatusComponent, TableValueComponent,
TableStatusComponent, TableValueComponent,
};
use crate::components::command::{self, CommandInfo};
use crate::config::KeyConfig;
@ -59,8 +59,8 @@ impl TableComponent {
database: Database,
table: DTable,
) {
self.selected_row.select(None);
if !rows.is_empty() {
self.selected_row.select(None);
self.selected_row.select(Some(0))
}
self.headers = headers;
@ -97,7 +97,7 @@ impl TableComponent {
let i = match self.selected_row.selected() {
Some(i) => {
if i + lines >= self.rows.len() {
Some(self.rows.len().saturating_sub(1))
Some(self.rows.len() - 1)
} else {
Some(i + lines)
}
@ -114,7 +114,7 @@ impl TableComponent {
if i <= lines {
Some(0)
} else {
Some(i.saturating_sub(lines))
Some(i - lines)
}
}
None => None,
@ -136,8 +136,7 @@ impl TableComponent {
return;
}
self.reset_selection();
self.selected_row
.select(Some(self.rows.len().saturating_sub(1)));
self.selected_row.select(Some(self.rows.len() - 1));
}
fn next_column(&mut self) {
@ -314,7 +313,7 @@ impl TableComponent {
)
.clamp(&3, &20)
});
if widths.iter().map(|(_, width)| width).sum::<usize>() + length + widths.len() + 1
if widths.iter().map(|(_, width)| width).sum::<usize>() + length + widths.len()
>= area_width.saturating_sub(number_column_width) as usize
{
column_index += 1;
@ -332,7 +331,7 @@ impl TableComponent {
let selected_column_index = widths.len().saturating_sub(1);
let mut column_index = far_right_column_index + 1;
while widths.iter().map(|(_, width)| width).sum::<usize>() + widths.len()
< area_width.saturating_sub(number_column_width) as usize
<= area_width.saturating_sub(number_column_width) as usize
{
let length = self
.rows
@ -400,20 +399,11 @@ impl TableComponent {
}
}
impl StatefulDrawableComponent for TableComponent {
impl DrawableComponent for TableComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let chunks = Layout::default()
.vertical_margin(1)
.horizontal_margin(1)
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(2),
]
.as_ref(),
)
.constraints(vec![Constraint::Length(3), Constraint::Length(5)])
.split(area);
f.render_widget(
@ -425,9 +415,16 @@ impl StatefulDrawableComponent for TableComponent {
} else {
Style::default().fg(Color::DarkGray)
}),
area,
layout[1],
);
let chunks = Layout::default()
.vertical_margin(1)
.horizontal_margin(1)
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(2)].as_ref())
.split(layout[1]);
self.selected_row.selected().map_or_else(
|| {
self.scroll.reset();
@ -436,11 +433,14 @@ impl StatefulDrawableComponent for TableComponent {
self.scroll.update(
selection,
self.rows.len(),
chunks[1].height.saturating_sub(2) as usize,
layout[1].height.saturating_sub(2) as usize,
);
},
);
TableValueComponent::new(self.selected_cells().unwrap_or_default())
.draw(f, layout[0], focused)?;
let block = Block::default().borders(Borders::NONE);
let (selected_column_index, headers, rows, constraints) =
self.calculate_cell_widths(block.inner(chunks[0]).width);
@ -485,7 +485,7 @@ impl StatefulDrawableComponent for TableComponent {
let mut state = self.selected_row.clone();
f.render_stateful_widget(
table,
chunks[1],
chunks[0],
if let Some((_, y)) = self.selection_area_corner {
state.select(Some(y));
&mut state
@ -494,9 +494,6 @@ impl StatefulDrawableComponent for TableComponent {
},
);
TableValueComponent::new(self.selected_cells().unwrap_or_default())
.draw(f, chunks[0], focused)?;
TableStatusComponent::new(
if self.rows.is_empty() {
None
@ -510,9 +507,9 @@ impl StatefulDrawableComponent for TableComponent {
},
self.table.as_ref().map(|t| t.1.clone()),
)
.draw(f, chunks[2], focused)?;
.draw(f, chunks[1], focused)?;
self.scroll.draw(f, chunks[1]);
self.scroll.draw(f, chunks[0]);
Ok(())
}
}

@ -1,9 +1,5 @@
use super::{
compute_character_width, CompletionComponent, Component, EventState, MovableComponent,
StatefulDrawableComponent,
};
use super::{compute_character_width, Component, DrawableComponent, EventState};
use crate::components::command::CommandInfo;
use crate::config::KeyConfig;
use crate::event::Key;
use anyhow::Result;
use database_tree::Table;
@ -18,26 +14,24 @@ use tui::{
use unicode_width::UnicodeWidthStr;
pub struct TableFilterComponent {
key_config: KeyConfig,
pub table: Option<Table>,
input: Vec<char>,
pub input: Vec<char>,
input_idx: usize,
input_cursor_position: u16,
completion: CompletionComponent,
}
impl TableFilterComponent {
pub fn new(key_config: KeyConfig) -> Self {
impl Default for TableFilterComponent {
fn default() -> Self {
Self {
key_config: key_config.clone(),
table: None,
input: Vec::new(),
input_idx: 0,
input_cursor_position: 0,
completion: CompletionComponent::new(key_config, "", false),
}
}
}
impl TableFilterComponent {
pub fn input_str(&self) -> String {
self.input.iter().collect()
}
@ -48,88 +42,9 @@ impl TableFilterComponent {
self.input_idx = 0;
self.input_cursor_position = 0;
}
fn update_completion(&mut self) {
let input = &self
.input
.iter()
.enumerate()
.filter(|(i, _)| i < &self.input_idx)
.map(|(_, i)| i)
.collect::<String>()
.split(' ')
.map(|i| i.to_string())
.collect::<Vec<String>>();
self.completion
.update(input.last().unwrap_or(&String::new()));
}
fn complete(&mut self) -> anyhow::Result<EventState> {
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::<Vec<String>>();
let last = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i >= &self.input_idx)
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
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::<Vec<String>>()
} else {
let mut c = candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>();
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 += middle
.join("")
.chars()
.map(compute_character_width)
.sum::<u16>();
if is_last_word {
self.input_cursor_position += " ".to_string().width() as u16
}
self.input_cursor_position -= self
.completion
.word()
.chars()
.map(compute_character_width)
.sum::<u16>();
self.update_completion();
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
}
impl StatefulDrawableComponent for TableFilterComponent {
impl DrawableComponent for TableFilterComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let query = Paragraph::new(Spans::from(vec![
Span::styled(
@ -154,24 +69,6 @@ impl StatefulDrawableComponent for TableFilterComponent {
})
.block(Block::default().borders(Borders::ALL));
f.render_widget(query, area);
if focused {
self.completion.draw(
f,
area,
false,
(self
.table
.as_ref()
.map_or(String::new(), |table| {
format!("{} ", table.name.to_string())
})
.width() as u16)
.saturating_add(self.input_cursor_position),
0,
)?;
};
if focused {
f.set_cursor(
(area.x
@ -181,8 +78,7 @@ impl StatefulDrawableComponent for TableFilterComponent {
.map_or(String::new(), |table| table.name.to_string())
.width()
+ 1) as u16)
.saturating_add(self.input_cursor_position)
.min(area.right().saturating_sub(2)),
.saturating_add(self.input_cursor_position),
area.y + 1,
)
}
@ -195,31 +91,21 @@ impl Component for TableFilterComponent {
fn event(&mut self, key: Key) -> Result<EventState> {
let input_str: String = self.input.iter().collect();
// apply comletion candidates
if key == self.key_config.enter {
return self.complete();
}
self.completion.selected_candidate();
match key {
Key::Char(c) => {
self.input.insert(self.input_idx, c);
self.input_idx += 1;
self.input_cursor_position += compute_character_width(c);
self.update_completion();
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Delete | Key::Backspace => {
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 -= compute_character_width(last_c);
self.completion.update("");
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Left => {
if !self.input.is_empty() && self.input_idx > 0 {
@ -227,75 +113,33 @@ impl Component for TableFilterComponent {
self.input_cursor_position = self
.input_cursor_position
.saturating_sub(compute_character_width(self.input[self.input_idx]));
self.completion.update("");
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Ctrl('a') => {
if !self.input.is_empty() && self.input_idx > 0 {
self.input_idx = 0;
self.input_cursor_position = 0
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Right => {
if self.input_idx < self.input.len() {
let next_c = self.input[self.input_idx];
self.input_idx += 1;
self.input_cursor_position += compute_character_width(next_c);
self.completion.update("");
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
Key::Ctrl('e') => {
if self.input_idx < self.input.len() {
self.input_idx = self.input.len();
self.input_cursor_position = self.input_str().width() as u16;
}
Ok(EventState::Consumed)
return Ok(EventState::Consumed);
}
key => self.completion.event(key),
_ => (),
}
}
}
#[cfg(test)]
mod test {
use super::{KeyConfig, TableFilterComponent};
#[test]
fn test_complete() {
let mut filter = TableFilterComponent::new(KeyConfig::default());
filter.input_idx = 2;
filter.input = vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g'];
filter.completion.update("an");
assert!(filter.complete().is_ok());
assert_eq!(
filter.input,
vec!['A', 'N', 'D', ' ', 'c', 'd', 'e', 'f', 'g']
);
}
#[test]
fn test_complete_end() {
let mut filter = TableFilterComponent::new(KeyConfig::default());
filter.input_idx = 9;
filter.input = vec!['a', 'b', ' ', 'c', 'd', 'e', 'f', ' ', 'i'];
filter.completion.update('i');
assert!(filter.complete().is_ok());
assert_eq!(
filter.input,
vec!['a', 'b', ' ', 'c', 'd', 'e', 'f', ' ', 'I', 'N', ' ']
);
}
#[test]
fn test_complete_no_candidates() {
let mut filter = TableFilterComponent::new(KeyConfig::default());
filter.input_idx = 2;
filter.input = vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g'];
filter.completion.update("foo");
assert!(filter.complete().is_ok());
assert_eq!(filter.input, vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g']);
Ok(EventState::NotConsumed)
}
}

@ -8,7 +8,7 @@ use tui::{
layout::Rect,
style::{Color, Style},
text::{Span, Spans},
widgets::{Block, Borders, Paragraph},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
@ -43,7 +43,7 @@ impl TableStatusComponent {
}
impl DrawableComponent for TableStatusComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let status = Paragraph::new(Spans::from(vec![
Span::from(format!(
"rows: {}, ",
@ -64,7 +64,8 @@ impl DrawableComponent for TableStatusComponent {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
}));
}))
.wrap(Wrap { trim: true });
f.render_widget(status, area);
Ok(())
}

@ -4,9 +4,10 @@ use crate::event::Key;
use anyhow::Result;
use tui::{
backend::Backend,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
@ -21,14 +22,24 @@ impl TableValueComponent {
}
impl DrawableComponent for TableValueComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
let paragraph = Paragraph::new(self.value.clone())
.block(Block::default().borders(Borders::BOTTOM))
.block(
Block::default()
.borders(Borders::ALL)
.style(Style::default())
.title(Span::styled(
"Value",
Style::default().add_modifier(Modifier::BOLD),
)),
)
.style(if focused {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
});
})
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
Ok(())
}

@ -4,12 +4,8 @@ use serde::Deserialize;
use std::fmt;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use structopt::StructOpt;
#[cfg(test)]
use serde::Serialize;
#[derive(StructOpt, Debug)]
pub struct CliConfig {
/// Set the config file
@ -51,7 +47,6 @@ impl Default for Config {
Self {
conn: vec![Connection {
r#type: DatabaseType::MySql,
name: None,
user: Some("root".to_string()),
host: Some("localhost".to_string()),
port: Some(3306),
@ -68,7 +63,6 @@ impl Default for Config {
#[derive(Debug, Deserialize, Clone)]
pub struct Connection {
r#type: DatabaseType,
name: Option<String>,
user: Option<String>,
host: Option<String>,
port: Option<u64>,
@ -78,14 +72,11 @@ pub struct Connection {
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(test, derive(Serialize))]
pub struct KeyConfig {
pub scroll_up: Key,
pub scroll_down: Key,
pub scroll_right: Key,
pub scroll_left: Key,
pub move_up: Key,
pub move_down: Key,
pub copy: Key,
pub enter: Key,
pub exit: Key,
@ -93,7 +84,6 @@ 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,
@ -110,10 +100,6 @@ pub struct KeyConfig {
pub tab_constraints: Key,
pub tab_foreign_keys: Key,
pub tab_indexes: Key,
pub tab_sql_editor: Key,
pub tab_properties: Key,
pub extend_or_shorten_widget_width_to_right: Key,
pub extend_or_shorten_widget_width_to_left: Key,
}
impl Default for KeyConfig {
@ -123,8 +109,6 @@ impl Default for KeyConfig {
scroll_down: Key::Char('j'),
scroll_right: Key::Char('l'),
scroll_left: Key::Char('h'),
move_up: Key::Up,
move_down: Key::Down,
copy: Key::Char('y'),
enter: Key::Enter,
exit: Key::Ctrl('c'),
@ -132,7 +116,6 @@ 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('/'),
@ -145,14 +128,10 @@ impl Default for KeyConfig {
extend_selection_by_one_cell_down: Key::Char('J'),
extend_selection_by_one_cell_up: Key::Char('K'),
tab_records: Key::Char('1'),
tab_properties: Key::Char('2'),
tab_sql_editor: Key::Char('3'),
tab_columns: Key::Char('4'),
tab_constraints: Key::Char('5'),
tab_foreign_keys: Key::Char('6'),
tab_indexes: Key::Char('7'),
extend_or_shorten_widget_width_to_right: Key::Char('>'),
extend_or_shorten_widget_width_to_left: Key::Char('<'),
tab_columns: Key::Char('2'),
tab_constraints: Key::Char('3'),
tab_foreign_keys: Key::Char('4'),
tab_indexes: Key::Char('5'),
}
}
}
@ -257,29 +236,14 @@ impl Connection {
DatabaseType::Sqlite => {
let path = self.path.as_ref().map_or(
Err(anyhow::anyhow!("type sqlite needs the path field")),
|path| {
expand_path(path).ok_or_else(|| anyhow::anyhow!("cannot expand file path"))
},
|path| Ok(path.to_str().unwrap()),
)?;
Ok(format!("sqlite://{path}", path = path.to_str().unwrap()))
Ok(format!("sqlite://{path}", path = path))
}
}
}
pub fn database_url_with_name(&self) -> anyhow::Result<String> {
let database_url = self.database_url()?;
Ok(match &self.name {
Some(name) => format!(
"[{name}] {database_url}",
name = name,
database_url = database_url
),
None => database_url,
})
}
pub fn is_mysql(&self) -> bool {
matches!(self.r#type, DatabaseType::MySql)
}
@ -301,112 +265,3 @@ pub fn get_app_config_path() -> anyhow::Result<std::path::PathBuf> {
std::fs::create_dir_all(&path)?;
Ok(path)
}
fn expand_path(path: &Path) -> Option<PathBuf> {
let mut expanded_path = PathBuf::new();
let mut path_iter = path.iter();
if path.starts_with("~") {
path_iter.next()?;
expanded_path = expanded_path.join(dirs_next::home_dir()?);
}
for path in path_iter {
let path = path.to_str()?;
expanded_path = if cfg!(unix) && path.starts_with('$') {
expanded_path.join(std::env::var(path.strip_prefix('$')?).unwrap_or_default())
} else if cfg!(windows) && path.starts_with('%') && path.ends_with('%') {
expanded_path
.join(std::env::var(path.strip_prefix('%')?.strip_suffix('%')?).unwrap_or_default())
} else {
expanded_path.join(path)
}
}
Some(expanded_path)
}
#[cfg(test)]
mod test {
use super::{expand_path, KeyConfig, Path, PathBuf};
use serde_json::Value;
use std::env;
#[test]
fn test_overlappted_key() {
let value: Value =
serde_json::from_str(&serde_json::to_string(&KeyConfig::default()).unwrap()).unwrap();
if let Value::Object(map) = value {
let mut values: Vec<String> = map
.values()
.map(|v| match v {
Value::Object(map) => Some(format!("{:?}", map)),
_ => None,
})
.flatten()
.collect();
values.sort();
let before_values = values.clone();
values.dedup();
pretty_assertions::assert_eq!(before_values, values);
}
}
#[test]
#[cfg(unix)]
fn test_expand_path() {
let home = env::var("HOME").unwrap();
let test_env = "baz";
env::set_var("TEST", test_env);
assert_eq!(
expand_path(&Path::new("$HOME/foo")),
Some(PathBuf::from(&home).join("foo"))
);
assert_eq!(
expand_path(&Path::new("$HOME/foo/$TEST/bar")),
Some(PathBuf::from(&home).join("foo").join(test_env).join("bar"))
);
assert_eq!(
expand_path(&Path::new("~/foo")),
Some(PathBuf::from(&home).join("foo"))
);
assert_eq!(
expand_path(&Path::new("~/foo/~/bar")),
Some(PathBuf::from(&home).join("foo").join("~").join("bar"))
);
}
#[test]
#[cfg(windows)]
fn test_expand_patha() {
let home = std::env::var("HOMEPATH").unwrap();
let test_env = "baz";
env::set_var("TEST", test_env);
assert_eq!(
expand_path(&Path::new("%HOMEPATH%/foo")),
Some(PathBuf::from(&home).join("foo"))
);
assert_eq!(
expand_path(&Path::new("%HOMEPATH%/foo/%TEST%/bar")),
Some(PathBuf::from(&home).join("foo").join(test_env).join("bar"))
);
assert_eq!(
expand_path(&Path::new("~/foo")),
Some(PathBuf::from(&dirs_next::home_dir().unwrap()).join("foo"))
);
assert_eq!(
expand_path(&Path::new("~/foo/~/bar")),
Some(
PathBuf::from(&dirs_next::home_dir().unwrap())
.join("foo")
.join("~")
.join("bar")
)
);
}
}

@ -12,8 +12,7 @@ use database_tree::{Child, Database, Table};
pub const RECORDS_LIMIT_PER_PAGE: u8 = 200;
#[async_trait]
pub trait Pool: Send + Sync {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult>;
pub trait Pool {
async fn get_databases(&self) -> anyhow::Result<Vec<Database>>;
async fn get_tables(&self, database: String) -> anyhow::Result<Vec<Child>>;
async fn get_records(
@ -46,26 +45,7 @@ pub trait Pool: Send + Sync {
async fn close(&self);
}
pub enum ExecuteResult {
Read {
headers: Vec<String>,
rows: Vec<Vec<String>>,
database: Database,
table: Table,
},
Write {
updated_rows: u64,
},
}
pub trait TableRow: std::marker::Send {
fn fields(&self) -> Vec<String>;
fn columns(&self) -> Vec<String>;
}
#[macro_export]
macro_rules! get_or_null {
($value:expr) => {
$value.map_or("NULL".to_string(), |v| v.to_string())
};
}

@ -1,25 +1,19 @@
use crate::get_or_null;
use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use database_tree::{Child, Database, Table};
use futures::TryStreamExt;
use sqlx::mysql::{MySqlColumn, MySqlPoolOptions, MySqlRow};
use sqlx::mysql::{MySqlColumn, MySqlPool as MPool, MySqlRow};
use sqlx::{Column as _, Row as _, TypeInfo as _};
use std::time::Duration;
pub struct MySqlPool {
pool: sqlx::mysql::MySqlPool,
pool: MPool,
}
impl MySqlPool {
pub async fn new(database_url: &str) -> anyhow::Result<Self> {
Ok(Self {
pool: MySqlPoolOptions::new()
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?,
pool: MPool::connect(database_url).await?,
})
}
}
@ -146,49 +140,6 @@ impl TableRow for Index {
#[async_trait]
impl Pool for MySqlPool {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult> {
let query = query.trim();
if query.to_uppercase().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<Vec<Database>> {
let databases = sqlx::query("SHOW DATABASES")
.fetch_all(&self.pool)
@ -398,69 +349,85 @@ impl Pool for MySqlPool {
fn convert_column_value_to_string(row: &MySqlRow, column: &MySqlColumn) -> anyhow::Result<String> {
let column_name = column.name();
if let Ok(value) = row.try_get(column_name) {
let value: Option<String> = value;
Ok(value.unwrap_or_else(|| "NULL".to_string()))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.unwrap_or_else(|| "NULL".to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<&str> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i8> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<f32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<f64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u8> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<u64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<rust_decimal::Decimal> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDate> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDateTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Utc>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<serde_json::Value> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<bool> = value;
Ok(get_or_null!(value))
} else {
anyhow::bail!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
)
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
Err(anyhow::anyhow!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
))
}

@ -1,14 +1,11 @@
use crate::get_or_null;
use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use database_tree::{Child, Database, Schema, Table};
use futures::TryStreamExt;
use itertools::Itertools;
use sqlx::postgres::{PgColumn, PgPool, PgPoolOptions, PgRow};
use sqlx::postgres::{PgColumn, PgPool, PgRow};
use sqlx::{Column as _, Row as _, TypeInfo as _};
use std::time::Duration;
pub struct PostgresPool {
pool: PgPool,
@ -17,10 +14,7 @@ pub struct PostgresPool {
impl PostgresPool {
pub async fn new(database_url: &str) -> anyhow::Result<Self> {
Ok(Self {
pool: PgPoolOptions::new()
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?,
pool: PgPool::connect(database_url).await?,
})
}
}
@ -147,47 +141,6 @@ impl TableRow for Index {
#[async_trait]
impl Pool for PostgresPool {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult> {
let query = query.trim();
if query.to_uppercase().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<Vec<Database>> {
let databases = sqlx::query("SELECT datname FROM pg_database")
.fetch_all(&self.pool)
@ -248,7 +201,7 @@ impl Pool for PostgresPool {
) -> anyhow::Result<(Vec<String>, Vec<Vec<String>>)> {
let query = if let Some(filter) = filter.as_ref() {
format!(
r#"SELECT * FROM "{database}"."{table_schema}"."{table}" WHERE {filter} LIMIT {limit} OFFSET {page}"#,
r#"SELECT * FROM "{database}""{table_schema}"."{table}" WHERE {filter} LIMIT {page}, {limit}"#,
database = database.name,
table = table.name,
filter = filter,
@ -258,7 +211,7 @@ impl Pool for PostgresPool {
)
} else {
format!(
r#"SELECT * FROM "{database}"."{table_schema}"."{table}" LIMIT {limit} OFFSET {page}"#,
r#"SELECT * FROM "{database}"."{table_schema}"."{table}" limit {limit} offset {page}"#,
database = database.name,
table = table.name,
table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()),
@ -299,15 +252,7 @@ impl Pool for PostgresPool {
serde_json::Value::Array(v) => {
new_row.push(v.iter().map(|v| v.to_string()).join(","))
}
serde_json::Value::Number(v) => new_row.push(v.to_string()),
serde_json::Value::Bool(v) => new_row.push(v.to_string()),
others => {
panic!(
"column type not implemented: `{}` {}",
column.name(),
others
)
}
_ => (),
}
}
}
@ -482,7 +427,7 @@ impl PostgresPool {
) -> anyhow::Result<Vec<serde_json::Value>> {
let query = if let Some(filter) = filter {
format!(
r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" WHERE {filter} LIMIT {limit} OFFSET {page}"#,
r#"SELECT to_json({table}.*) FROM "{database}""{table_schema}"."{table}" WHERE {filter} LIMIT {page}, {limit}"#,
database = database.name,
table = table.name,
filter = filter,
@ -492,7 +437,7 @@ impl PostgresPool {
)
} else {
format!(
r#"SELECT to_json("{table}".*) FROM "{database}"."{table_schema}"."{table}" LIMIT {limit} OFFSET {page}"#,
r#"SELECT to_json({table}.*) FROM "{database}"."{table_schema}"."{table}" limit {limit} offset {page}"#,
database = database.name,
table = table.name,
table_schema = table.schema.clone().unwrap_or_else(|| "public".to_string()),
@ -510,19 +455,23 @@ fn convert_column_value_to_string(row: &PgRow, column: &PgColumn) -> anyhow::Res
let column_name = column.name();
if let Ok(value) = row.try_get(column_name) {
let value: Option<i16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<rust_decimal::Decimal> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<&[u8]> = value;
Ok(value.map_or("NULL".to_string(), |values| {
return Ok(value.map_or("NULL".to_string(), |values| {
format!(
"\\x{}",
values
@ -530,42 +479,51 @@ fn convert_column_value_to_string(row: &PgRow, column: &PgColumn) -> anyhow::Res
.map(|v| format!("{:02x}", v))
.collect::<String>()
)
}))
} else if let Ok(value) = row.try_get(column_name) {
}));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDate> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: String = value;
Ok(value)
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value);
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Utc>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Local>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDateTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDate> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<serde_json::Value> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get::<Option<bool>, _>(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get::<Option<bool>, _>(column_name) {
let value: Option<bool> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<Vec<String>> = value;
Ok(value.map_or("NULL".to_string(), |v| v.join(",")))
} else {
anyhow::bail!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
)
return Ok(value.map_or("NULL".to_string(), |v| v.join(",")));
}
Err(anyhow::anyhow!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
))
}

@ -1,25 +1,19 @@
use crate::get_or_null;
use super::{ExecuteResult, Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use super::{Pool, TableRow, RECORDS_LIMIT_PER_PAGE};
use async_trait::async_trait;
use chrono::NaiveDateTime;
use database_tree::{Child, Database, Table};
use futures::TryStreamExt;
use sqlx::sqlite::{SqliteColumn, SqlitePoolOptions, SqliteRow};
use sqlx::sqlite::{SqliteColumn, SqlitePool as SPool, SqliteRow};
use sqlx::{Column as _, Row as _, TypeInfo as _};
use std::time::Duration;
pub struct SqlitePool {
pool: sqlx::sqlite::SqlitePool,
pool: SPool,
}
impl SqlitePool {
pub async fn new(database_url: &str) -> anyhow::Result<Self> {
Ok(Self {
pool: SqlitePoolOptions::new()
.connect_timeout(Duration::from_secs(5))
.connect(database_url)
.await?,
pool: SPool::connect(database_url).await?,
})
}
}
@ -150,47 +144,6 @@ impl TableRow for Index {
#[async_trait]
impl Pool for SqlitePool {
async fn execute(&self, query: &String) -> anyhow::Result<ExecuteResult> {
let query = query.trim();
if query.to_uppercase().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<Vec<Database>> {
let databases = sqlx::query("SELECT name FROM pragma_database_list")
.fetch_all(&self.pool)
@ -390,42 +343,51 @@ fn convert_column_value_to_string(
let column_name = column.name();
if let Ok(value) = row.try_get(column_name) {
let value: Option<String> = value;
Ok(value.unwrap_or_else(|| "NULL".to_string()))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.unwrap_or_else(|| "NULL".to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<&str> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i16> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<i64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<f32> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<f64> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Utc>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<chrono::DateTime<chrono::Local>> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<NaiveDateTime> = value;
Ok(get_or_null!(value))
} else if let Ok(value) = row.try_get(column_name) {
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
if let Ok(value) = row.try_get(column_name) {
let value: Option<bool> = value;
Ok(get_or_null!(value))
} else {
anyhow::bail!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
)
return Ok(value.map_or("NULL".to_string(), |v| v.to_string()));
}
Err(anyhow::anyhow!(
"column type not implemented: `{}` {}",
column_name,
column.type_info().clone().name()
))
}

@ -2,12 +2,8 @@ use crossterm::event;
use serde::Deserialize;
use std::fmt;
#[cfg(test)]
use serde::Serialize;
/// Represents a key.
#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug, Deserialize)]
#[cfg_attr(test, derive(Serialize))]
pub enum Key {
/// Both Enter (or Return) and numpad Enter
Enter,
@ -69,7 +65,7 @@ pub enum Key {
Char(char),
Ctrl(char),
Alt(char),
Unknown,
Unkown,
}
impl Key {
@ -207,7 +203,7 @@ impl From<event::KeyEvent> for Key {
..
} => Key::Char(c),
_ => Key::Unknown,
_ => Key::Unkown,
}
}
}

@ -61,21 +61,3 @@ 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");
}
}
}

@ -2,11 +2,8 @@ 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<MoveSelection> {
if key == key_config.scroll_down {
@ -25,8 +22,6 @@ pub fn common_nav(key: Key, key_config: &KeyConfig) -> Option<MoveSelection> {
Some(MoveSelection::Top)
} else if key == key_config.scroll_to_bottom {
Some(MoveSelection::End)
} else if key == key_config.enter {
Some(MoveSelection::Enter)
} else {
None
}

@ -1,545 +0,0 @@
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<Item = StyledGrapheme<'a>>,
max_line_width: u16,
current_line: Vec<StyledGrapheme<'a>>,
next_line: Vec<StyledGrapheme<'a>>,
/// Removes the leading whitespace from lines
trim: bool,
}
impl<'a, 'b> WordWrapper<'a, 'b> {
pub fn new(
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
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::<u16>::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::<u16>::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<Item = StyledGrapheme<'a>>,
max_line_width: u16,
current_line: Vec<StyledGrapheme<'a>>,
/// Record the offet to skip render
horizontal_offset: u16,
}
impl<'a, 'b> LineTruncator<'a, 'b> {
pub fn new(
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
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::<u16>::cast(symbol.width()) > self.max_line_width {
continue;
}
// Break on newline and discard it.
if symbol == "\n" {
break;
}
if current_line_width + Cast::<u16>::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::<u16>::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<String>, Vec<u16>) {
let style = Default::default();
let mut styled =
UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style });
let mut composer: Box<dyn LineComposer> = 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::<String>();
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!"
]
);
}
}

@ -1,182 +0,0 @@
#![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<Block<'a>>,
/// Widget style
style: Style,
/// How to wrap the text
wrap: Option<Wrap>,
/// 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<T>(text: T) -> Self
where
T: Into<Text<'a>>,
{
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<dyn LineComposer> = 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::<u16>::cast(symbol.width());
}
}
y += 1;
if y >= text_area.height + state.scroll.y {
end_reached = true;
}
}
state.lines = y;
state.height = area.height;
}
}

@ -1,106 +0,0 @@
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<usize>)>,
}
pub struct SyntaxText {
text: String,
lines: Vec<SyntaxLine>,
}
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<SyntaxLine> = 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<Spans> = 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<Spans> = 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
}
Loading…
Cancel
Save