From e30e867933c7cbe5e3284a4e0298b8ca80714ddb Mon Sep 17 00:00:00 2001 From: Karcsesz Date: Tue, 7 May 2024 18:03:25 +0200 Subject: [PATCH] First UX pass of new list command --- Cargo.lock | 287 +++++++++++++++++++++++++++++++++++- Cargo.toml | 4 +- src/editor/commands/list.rs | 177 +++++++++++++++++++++- 3 files changed, 463 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76f8a0c..e82dd79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -32,6 +44,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "anstream" version = "0.6.11" @@ -226,6 +244,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.83" @@ -275,7 +308,7 @@ version = "4.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -293,6 +326,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "crc32fast" version = "1.3.2" @@ -302,6 +348,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "either" version = "1.10.0" @@ -330,8 +401,10 @@ version = "0.1.1" dependencies = [ "axum", "clap", + "crossterm", "nix", "qpidfile", + "ratatui", "reqwest", "serde", "serde_json", @@ -436,6 +509,22 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -569,12 +658,27 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "ipnet" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -608,12 +712,31 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown", +] + [[package]] name = "matchit" version = "0.7.3" @@ -648,6 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -705,6 +829,35 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking_lot" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.0", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -767,6 +920,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "itertools", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +dependencies = [ + "bitflags", +] + [[package]] name = "reqwest" version = "0.12.2" @@ -896,6 +1078,12 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.197" @@ -958,6 +1146,27 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -998,12 +1207,50 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stability" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.5.0" @@ -1257,6 +1504,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + +[[package]] +name = "unicode-width" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" + [[package]] name = "untrusted" version = "0.9.0" @@ -1292,6 +1551,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "want" version = "0.3.1" @@ -1574,6 +1839,26 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 64cc07b..85ad7b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" [features] default = ["server", "editor"] server = ["tokio", "qpidfile", "axum"] -editor = ["reqwest", "tempfile", "which", "nix"] +editor = ["reqwest", "tempfile", "which", "nix", "ratatui", "crossterm"] [dependencies] qpidfile = { version = "0.9.2", optional = true } @@ -26,6 +26,8 @@ tempfile = { version = "3.10.1", optional = true } which = { version = "6.0.1", optional = true } nix = { version = "0.28.0", optional = true, default-features = false, features = ["signal"] } urlencoding = { version = "2.1.3"} +ratatui = { version = "0.26.2", optional = true } +crossterm = { version = "0.27.0", optional = true } [profile.release] # 💛 @Ryze@equestria.social strip = "symbols" diff --git a/src/editor/commands/list.rs b/src/editor/commands/list.rs index f3cdfcc..d45a2b4 100644 --- a/src/editor/commands/list.rs +++ b/src/editor/commands/list.rs @@ -1,9 +1,180 @@ +use std::io; +use std::io::{Stdout, stdout}; use crate::schema::resource_list::ResourceList; use std::path::PathBuf; +use crossterm::{event, ExecutableCommand, execute}; +use crossterm::event::{Event, KeyCode, KeyEventKind}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use thiserror::Error; + +#[derive(Debug, Error)] +enum TerminalSetupError { + #[error("failed to enable raw mode: {0}")] + EnableRawMode(io::Error), + #[error("failed to enter alternate screen: {0}")] + EnterAlternateScreen(io::Error), + #[error("failed to create Ratatui terminal: {0}")] + TerminalCreation(io::Error), +} + +#[derive(Debug, Error)] +enum TerminalRestore { + #[error("failed to disable raw mode for terminal: {0}")] + DisableRawMode(io::Error), + #[error("failed to leave alternate screen: {0}")] + LeaveAlternateScreen(io::Error), +} + +fn setup_terminal() -> Result>, TerminalSetupError> { + let mut stdout = std::io::stdout(); + enable_raw_mode().map_err(TerminalSetupError::EnableRawMode)?; + execute!(stdout, EnterAlternateScreen).map_err(TerminalSetupError::EnterAlternateScreen)?; + Terminal::new(CrosstermBackend::new(stdout)).map_err(TerminalSetupError::TerminalCreation) +} + +struct ResourceListGUI { + resources: ResourceList, + list_state: ListState, +} + +impl ResourceListGUI { + fn new(resources: ResourceList) -> Self { + Self { + resources, + list_state: Default::default(), + } + } + + fn draw(&mut self, terminal: &mut Terminal) { + terminal.draw(|f| f.render_widget(self, f.size())).expect("Failed to draw self"); + } + + fn run(mut self, terminal: &mut Terminal) { + loop { + self.draw(terminal); + + if let Event::Key(key) = event::read().unwrap() { + if key.kind == KeyEventKind::Press { + use KeyCode::*; + match key.code { + Char('q') | Esc => return, + Char('j') | Down => self.next_item(), + Char('k') | Up => self.previous_item(), + Home => self.go_top(), + End => self.go_bottom(), + Char('d') => self.delete_current(), + _ => {} + } + } + } + } + } + + fn next_item(&mut self) { + if self.resources.0.is_empty() {return} + let next_selected = if let Some(current_selected) = self.list_state.selected() { + if current_selected >= self.resources.0.len() - 1 { + 0 + } else { + current_selected + 1 + } + } else { + 0 + }; + + self.list_state.select(Some(next_selected)) + } + + fn previous_item(&mut self) { + if self.resources.0.is_empty() {return} + let next_selected = if let Some(current_selected) = self.list_state.selected() { + if current_selected == 0 { + self.resources.0.len()-1 + } else { + current_selected - 1 + } + } else { + self.resources.0.len()-1 + }; + + self.list_state.select(Some(next_selected)) + } + + fn go_top(&mut self) { + if self.resources.0.is_empty() {return} + self.list_state.select(Some(0)) + } + + fn go_bottom(&mut self) { + if self.resources.0.is_empty() {return} + self.list_state.select(Some(self.resources.0.len() - 1)) + } + + fn delete_current(&mut self) { + if let Some(selected) = self.list_state.selected() { + self.resources.0.remove(selected); + + let next_selected = if self.resources.0.is_empty() { + None + } else { + Some(selected - 1) + }; + + self.list_state.select(next_selected); + } + } +} + +impl Widget for &mut ResourceListGUI { + fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized { + let [body, footer] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + ]).areas(area); + + let [list_area, details] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Fill(2) + ]).areas(body); + + let list_block = Block::new() + .borders(Borders::TOP) + .title("Resources"); + + let items = self.resources.0.iter().map(|resource| ListItem::new(resource.subject.clone())); + let list = List::new(items) + .block(list_block) + .highlight_symbol(">"); + StatefulWidget::render(list, list_area, buf, &mut self.list_state); + + if let Some(selected) = self.list_state.selected() { + let selected = &self.resources.0.get(selected).unwrap(); + let details_block = Block::new() + .borders(Borders::LEFT | Borders::TOP) + .title("Resource details"); + let text = Paragraph::new(serde_json::to_string_pretty(selected).unwrap_or_default()) + .block(details_block); + text.render(details, buf); + } + + let footer_text = Paragraph::new("Arrow keys navigate | d: delete") + .centered() + .on_dark_gray(); + footer_text.render(footer, buf); + } +} + +fn restore_terminal() -> Result<(), TerminalRestore>{ + disable_raw_mode().map_err(TerminalRestore::DisableRawMode)?; + stdout().execute(LeaveAlternateScreen).map_err(TerminalRestore::LeaveAlternateScreen)?; + Ok(()) +} pub fn list(database_path: PathBuf) { let resources = ResourceList::load(database_path).unwrap(); - for resource in resources.0 { - println!("{}", resource.subject) - } + let mut terminal = setup_terminal().unwrap(); + ResourceListGUI::new(resources).run(&mut terminal); + restore_terminal().unwrap(); }