diff --git a/Cargo.lock b/Cargo.lock index ac0911a96f..512ec863be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,9 +26,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.22.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -607,9 +607,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", @@ -665,6 +665,16 @@ dependencies = [ "serde", ] +[[package]] +name = "better-panic" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa9e1d11a268684cbd90ed36370d7577afb6c62d912ddff5c15fc34343e5036" +dependencies = [ + "backtrace", + "console", +] + [[package]] name = "binascii" version = "0.1.4" @@ -943,6 +953,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" @@ -955,6 +971,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.1.18" @@ -1158,6 +1183,33 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.1" @@ -1193,7 +1245,22 @@ dependencies = [ "crossterm 0.27.0", "strum 0.26.3", "strum_macros 0.26.4", - "unicode-width", + "unicode-width 0.1.13", +] + +[[package]] +name = "compact_str" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" +dependencies = [ + "castaway 0.2.3", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", ] [[package]] @@ -1214,7 +1281,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", - "unicode-width", + "unicode-width 0.1.13", "windows-sys 0.52.0", ] @@ -1634,6 +1701,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.5.0", + "crossterm_winapi", + "futures-core", + "mio 1.0.1", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1915,12 +1999,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core 0.20.9", - "darling_macro 0.20.9", + "darling_core 0.20.10", + "darling_macro 0.20.10", ] [[package]] @@ -1939,9 +2023,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", @@ -1964,11 +2048,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core 0.20.9", + "darling_core 0.20.10", "quote", "syn 2.0.87", ] @@ -2634,6 +2718,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2798,6 +2888,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "gcc" version = "0.3.55" @@ -2875,9 +2974,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "glob" @@ -3028,6 +3127,17 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -3512,9 +3622,15 @@ dependencies = [ "instant", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.1.13", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inlinable_string" version = "0.1.15" @@ -3564,7 +3680,21 @@ dependencies = [ "newline-converter", "thiserror", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.13", +] + +[[package]] +name = "instability" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b829f37dead9dc39df40c2d3376c179fdfd2ac771f53f55d3c30dc096a3c0c6e" +dependencies = [ + "darling 0.20.10", + "indoc", + "pretty_assertions", + "proc-macro2", + "quote", + "syn 2.0.87", ] [[package]] @@ -3642,7 +3772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" dependencies = [ "async-channel", - "castaway", + "castaway 0.1.2", "crossbeam-utils", "curl", "curl-sys", @@ -3914,6 +4044,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.1", +] + [[package]] name = "macro_rules_attribute" version = "0.1.3" @@ -4099,6 +4238,7 @@ checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4" dependencies = [ "hermit-abi 0.3.9", "libc", + "log", "wasi", "windows-sys 0.52.0", ] @@ -6674,6 +6814,27 @@ dependencies = [ "wasm-utils", ] +[[package]] +name = "nym-tui-common" +version = "0.1.0" +dependencies = [ + "async-trait", + "better-panic", + "color-eyre", + "crossterm 0.28.1", + "humantime-serde", + "ratatui", + "serde", + "strip-ansi-escapes", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-subscriber", + "tui-logger", +] + [[package]] name = "nym-tun" version = "0.1.0" @@ -6961,9 +7122,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -7145,6 +7306,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "pairing" version = "0.23.0" @@ -7804,6 +7971,28 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.5.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "serde", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-cpuid" version = "11.2.0" @@ -8798,7 +8987,7 @@ version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" dependencies = [ - "darling 0.20.9", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.87", @@ -8911,12 +9100,13 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio 0.8.11", + "mio 1.0.1", "signal-hook", ] @@ -9347,6 +9537,15 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.10.0" @@ -10174,6 +10373,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-futures" version = "0.2.5" @@ -10355,6 +10564,22 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "tui-logger" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdf8f5ebd2c83a5176c69b150ea7f2a855ec8dc2a2774e7f198d1b50feda5745" +dependencies = [ + "chrono", + "fxhash", + "lazy_static", + "log", + "parking_lot", + "ratatui", + "tracing", + "tracing-subscriber", +] + [[package]] name = "tungstenite" version = "0.20.1" @@ -10480,12 +10705,29 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.13", +] + [[package]] name = "unicode-width" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.4" @@ -10817,6 +11059,26 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "waker-fn" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 7735cb8f59..5db423393d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,7 +149,7 @@ members = [ "tools/internal/contract-state-importer/importer-cli", "tools/internal/contract-state-importer/importer-contract", "tools/internal/testnet-manager", - "tools/internal/testnet-manager/dkg-bypass-contract", + "tools/internal/testnet-manager/dkg-bypass-contract", "common/nym-tui-common", ] default-members = [ @@ -412,6 +412,15 @@ wasm-bindgen-futures = "0.4.45" wasmtimer = "0.2.0" web-sys = "0.3.72" +# tui related +better-panic = "0.3.0" +color-eyre = "0.6.3" +crossterm = "0.28.1" +strip-ansi-escapes = "0.2.0" +ratatui = "0.29.0" +tui-logger = "0.14.0" + + # Profile settings for individual crates # Compile-time verified queries do quite a bit of work at compile time. Incremental diff --git a/common/nym-tui-common/Cargo.toml b/common/nym-tui-common/Cargo.toml new file mode 100644 index 0000000000..effca94989 --- /dev/null +++ b/common/nym-tui-common/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "nym-tui-common" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +async-trait = { workspace = true } +humantime-serde = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true, features = ["sync", "rt-multi-thread", "signal", "macros"] } +tokio-stream = { workspace = true } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +thiserror = { workspace = true } + +# tui +crossterm = { workspace = true, features = ["event-stream"] } +ratatui = { workspace = true, features = ["serde", "macros", "crossterm"] } +tui-logger = { workspace = true, optional = true, features = ["tracing-support"], default-features = false } + +# panic handlers +better-panic = { workspace = true } +color-eyre = { workspace = true } +strip-ansi-escapes = { workspace = true } + +[dev-dependencies] +tracing-subscriber = { workspace = true, features = ["env-filter"] } +tracing = { workspace = true, features = ["log"] } + +[[example]] +name = "hello_world" +required-features = ["logger"] + + + + +[features] +logger = ["tui-logger"] \ No newline at end of file diff --git a/common/nym-tui-common/examples/hello_world.rs b/common/nym-tui-common/examples/hello_world.rs new file mode 100644 index 0000000000..545cc8b873 --- /dev/null +++ b/common/nym-tui-common/examples/hello_world.rs @@ -0,0 +1,175 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use color_eyre::eyre; +use crossterm::event::{KeyCode, KeyEvent}; +use nym_tui_common::tui::config::keybindings::{KeyBinding, LoggerKeybindings}; +use nym_tui_common::{ + run_tui, Action, ActionDispatcher, ActionSender, AppAction, Component, DebugHistory, Logger, + LoggerProps, State, +}; +use ratatui::layout::{Layout, Rect}; +use ratatui::prelude::Constraint; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use tracing::log::LevelFilter; +use tracing_subscriber::EnvFilter; + +// useful pattern for tabs, etc: +/* +fn get_active_page_component_mut(&mut self) -> &mut dyn Component { + match self.props.active_tab { + ActiveTab::Tab1 => &mut self.tab1, + ActiveTab::Tab2 => &mut self.tab2, + } +} + */ + +struct Props { + custom_quit: KeyBinding, +} + +impl From<&HelloStore> for Props { + fn from(store: &HelloStore) -> Self { + Props { + custom_quit: store.config.custom_quit, + } + } +} + +pub struct HelloRootApp { + props: Props, + + action_sender: ActionSender, + logger: Logger, + debug_history: DebugHistory, +} + +impl Component for HelloRootApp { + type State = HelloStore; + type Actions = HelloActions; + + fn new(state: &HelloStore, action_sender: ActionSender) -> Self + where + Self: Sized, + { + HelloRootApp { + props: Props::from(state), + action_sender: action_sender.clone(), + logger: Logger::new(state, action_sender.clone()), + debug_history: DebugHistory::new(state, action_sender), + } + } + + fn update(self, state: &Self::State) -> Self + where + Self: Sized, + { + HelloRootApp { + logger: self.logger.update(state), + debug_history: self.debug_history.update(state), + ..self + } + } + + fn tick(&mut self) -> bool { + let logger_tick = self.logger.tick(); + let debug_history_tick = self.debug_history.tick(); + + logger_tick || debug_history_tick + } + + fn handle_key(&mut self, key: KeyEvent) -> eyre::Result<()> { + let maybe_binding = KeyBinding::from(key); + if maybe_binding == self.props.custom_quit { + self.action_sender.send(Action::Quit); + } + + self.logger.handle_key(key)?; + self.debug_history.handle_key(key)?; + + Ok(()) + } + + fn view(&mut self, frame: &mut Frame, rect: Rect) { + let [logs, hello_rect] = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(rect); + + self.logger.view(frame, logs); + + frame.render_widget(Paragraph::new("Hello world!").centered(), hello_rect); + + self.debug_history.view(frame, rect); + } +} + +#[derive(Debug, Clone)] +pub enum HelloActions {} + +impl AppAction for HelloActions {} + +#[derive(Clone)] +pub struct HelloConfig { + pub custom_quit: KeyBinding, + pub logger_keybindings: LoggerKeybindings, +} + +impl Default for HelloConfig { + fn default() -> Self { + HelloConfig { + custom_quit: KeyBinding::new(KeyCode::Char('x')), + logger_keybindings: Default::default(), + } + } +} + +#[derive(Clone, Default)] +pub struct HelloStore { + config: HelloConfig, +} + +impl<'a> From<&'a HelloStore> for LoggerProps { + fn from(store: &'a HelloStore) -> LoggerProps { + LoggerProps { + keybindings: store.config.logger_keybindings, + } + } +} + +impl State for HelloStore {} + +#[derive(Default)] +pub struct HelloDispatcher {} + +#[async_trait] +impl ActionDispatcher for HelloDispatcher { + type Store = HelloStore; + type Actions = HelloActions; + + async fn handle_app_action( + &mut self, + action: Self::Actions, + store: &mut Self::Store, + ) -> eyre::Result<()> { + let _ = action; + let _ = store; + Ok(()) + } +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + use tracing_subscriber::Layer; + + let filter: EnvFilter = "trace,mio=warn".parse()?; + + tracing_subscriber::registry() + .with(tui_logger::tracing_subscriber_layer().with_filter(filter)) + .init(); + tui_logger::init_logger(LevelFilter::Trace)?; + + run_tui::(Default::default(), HelloDispatcher {}, Default::default()).await +} diff --git a/common/nym-tui-common/src/error.rs b/common/nym-tui-common/src/error.rs new file mode 100644 index 0000000000..b8af944ffd --- /dev/null +++ b/common/nym-tui-common/src/error.rs @@ -0,0 +1,24 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::char::ParseCharError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NymTuiError { + #[error("failed to abort tui processing task within specified duration")] + TuiTaskAbortFailure, + + #[error("{str} could not be parsed into a character: {source}")] + InvalidCharacter { + str: String, + #[source] + source: ParseCharError, + }, + + #[error("could not process an unknown keybinding: '{value}'")] + UnknownKeyBinding { value: String }, + + #[error("could not process an unknown key modifier: '{value}'")] + UnknownKeyModifier { value: String }, +} diff --git a/common/nym-tui-common/src/lib.rs b/common/nym-tui-common/src/lib.rs new file mode 100644 index 0000000000..67b8a89dc7 --- /dev/null +++ b/common/nym-tui-common/src/lib.rs @@ -0,0 +1,46 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#![warn(clippy::expect_used)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::todo)] +#![warn(clippy::dbg_macro)] +#![warn(clippy::panic)] + +use crate::tui::manager::TuiManager; +use color_eyre::eyre; + +pub mod error; +pub mod tui; + +pub use crate::tui::config::TuiConfig; +pub use tui::action::{Action, AppAction}; +pub use tui::dispatcher::store::State; +pub use tui::dispatcher::{ActionDispatcher, ActionSender}; +pub use tui::initialize_panic_handler; +pub use tui::ui::components::Component; + +// components: +pub use tui::ui::components::common::DebugHistory; +#[cfg(feature = "logger")] +pub use tui::ui::components::common::{Logger, LoggerProps}; + +pub async fn run_tui( + config: TuiConfig, + action_dispatcher: D, + initial_state: D::Store, +) -> eyre::Result<()> +where + C: Component + Send + Sync + 'static, + C::State: Send + Sync + 'static, + C::Actions: Send + Sync + Clone + 'static, + D: ActionDispatcher + Send + Sync + 'static, +{ + initialize_panic_handler()?; + + TuiManager::::build_new(config, action_dispatcher, initial_state)? + .wait_for_exit_or_signal() + .await; + + Ok(()) +} diff --git a/common/nym-tui-common/src/tui/action.rs b/common/nym-tui-common/src/tui/action.rs new file mode 100644 index 0000000000..abe4292683 --- /dev/null +++ b/common/nym-tui-common/src/tui/action.rs @@ -0,0 +1,13 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Debug; + +#[derive(Debug)] +pub enum Action { + Quit, + + AppDefined(T), +} + +pub trait AppAction: Debug {} diff --git a/common/nym-tui-common/src/tui/config/keybindings.rs b/common/nym-tui-common/src/tui/config/keybindings.rs new file mode 100644 index 0000000000..4b3bdda05d --- /dev/null +++ b/common/nym-tui-common/src/tui/config/keybindings.rs @@ -0,0 +1,271 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use std::sync::LazyLock; + +use crate::error::NymTuiError; +#[cfg(feature = "logger")] +use tui_logger::TuiWidgetEvent; + +static KEY_MODIFIERS: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + m.insert("shift", KeyModifiers::SHIFT); + m.insert("ctrl", KeyModifiers::CONTROL); + m.insert("alt", KeyModifiers::ALT); + m.insert("super", KeyModifiers::SUPER); + m.insert("hyper", KeyModifiers::HYPER); + m.insert("meta", KeyModifiers::META); + m +}); + +static SPECIAL_KEYS: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + m.insert("backspace", KeyCode::Backspace); + m.insert("enter", KeyCode::Enter); + m.insert("left", KeyCode::Left); + m.insert("right", KeyCode::Right); + m.insert("up", KeyCode::Up); + m.insert("down", KeyCode::Down); + m.insert("home", KeyCode::Home); + m.insert("end", KeyCode::End); + m.insert("pageup", KeyCode::PageUp); + m.insert("pagedown", KeyCode::PageDown); + m.insert("tab", KeyCode::Tab); + m.insert("backtab", KeyCode::BackTab); + m.insert("delete", KeyCode::Delete); + m.insert("insert", KeyCode::Insert); + m.insert("null", KeyCode::Null); + m.insert("esc", KeyCode::Esc); + m.insert("space", KeyCode::Char(' ')); + m.insert("f1", KeyCode::F(1)); + m.insert("f2", KeyCode::F(2)); + m.insert("f3", KeyCode::F(3)); + m.insert("f4", KeyCode::F(4)); + m.insert("f5", KeyCode::F(5)); + m.insert("f6", KeyCode::F(6)); + m.insert("f7", KeyCode::F(7)); + m.insert("f8", KeyCode::F(8)); + m.insert("f9", KeyCode::F(9)); + m.insert("f10", KeyCode::F(10)); + m.insert("f11", KeyCode::F(11)); + m.insert("f12", KeyCode::F(12)); + m +}); + +#[cfg(feature = "logger")] +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "kebab-case", deny_unknown_fields, default)] +pub struct LoggerKeybindings { + // TODO: give them better names + tui_logger_space_key: KeyBinding, + tui_logger_up_key: KeyBinding, + tui_logger_down_key: KeyBinding, + tui_logger_left_key: KeyBinding, + tui_logger_right_key: KeyBinding, + tui_logger_plus_key: KeyBinding, + tui_logger_minus_key: KeyBinding, + tui_logger_hide_key: KeyBinding, + tui_logger_focus_key: KeyBinding, + tui_logger_prev_page_key: KeyBinding, + tui_logger_next_page_key: KeyBinding, + tui_logger_escape_key: KeyBinding, +} + +#[cfg(feature = "logger")] +impl Default for LoggerKeybindings { + fn default() -> Self { + LoggerKeybindings { + tui_logger_space_key: KeyBinding::new(KeyCode::Char(' ')), + tui_logger_up_key: KeyBinding::new(KeyCode::Up), + tui_logger_down_key: KeyBinding::new(KeyCode::Down), + tui_logger_left_key: KeyBinding::new(KeyCode::Left), + tui_logger_right_key: KeyBinding::new(KeyCode::Right), + tui_logger_plus_key: KeyBinding::new(KeyCode::Char('+')), + tui_logger_minus_key: KeyBinding::new(KeyCode::Char('-')), + tui_logger_hide_key: KeyBinding::new(KeyCode::Char('h')), + tui_logger_focus_key: KeyBinding::new(KeyCode::Char('f')), + tui_logger_prev_page_key: KeyBinding::new(KeyCode::PageUp), + tui_logger_next_page_key: KeyBinding::new(KeyCode::PageDown), + tui_logger_escape_key: KeyBinding::new(KeyCode::Esc), + } + } +} + +#[cfg(feature = "logger")] +impl LoggerKeybindings { + pub fn tui_logger_event(&self, key: KeyBinding) -> Option { + if key == self.tui_logger_space_key { + Some(TuiWidgetEvent::SpaceKey) + } else if key == self.tui_logger_up_key { + Some(TuiWidgetEvent::UpKey) + } else if key == self.tui_logger_down_key { + Some(TuiWidgetEvent::DownKey) + } else if key == self.tui_logger_left_key { + Some(TuiWidgetEvent::LeftKey) + } else if key == self.tui_logger_right_key { + Some(TuiWidgetEvent::RightKey) + } else if key == self.tui_logger_plus_key { + Some(TuiWidgetEvent::PlusKey) + } else if key == self.tui_logger_minus_key { + Some(TuiWidgetEvent::MinusKey) + } else if key == self.tui_logger_hide_key { + Some(TuiWidgetEvent::HideKey) + } else if key == self.tui_logger_focus_key { + Some(TuiWidgetEvent::FocusKey) + } else if key == self.tui_logger_prev_page_key { + Some(TuiWidgetEvent::PrevPageKey) + } else if key == self.tui_logger_next_page_key { + Some(TuiWidgetEvent::NextPageKey) + } else if key == self.tui_logger_escape_key { + Some(TuiWidgetEvent::EscapeKey) + } else { + None + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq, Hash)] +#[serde(try_from = "String")] +#[serde(into = "String")] +pub struct KeyBinding { + pub code: KeyCode, + pub modifier: KeyModifiers, +} + +impl KeyBinding { + pub fn new(code: KeyCode) -> Self { + KeyBinding { + code, + modifier: KeyModifiers::NONE, + } + } + + pub fn new_with_modifier(code: KeyCode, modifier: KeyModifiers) -> Self { + KeyBinding { code, modifier } + } +} + +impl From for KeyBinding { + fn from(value: KeyEvent) -> Self { + KeyBinding { + code: value.code, + modifier: value.modifiers, + } + } +} + +impl Display for KeyBinding { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.modifier.contains(KeyModifiers::SHIFT) { + write!(f, "shift+")?; + } + if self.modifier.contains(KeyModifiers::CONTROL) { + write!(f, "ctrl+")?; + } + if self.modifier.contains(KeyModifiers::ALT) { + write!(f, "alt+")?; + } + if self.modifier.contains(KeyModifiers::SUPER) { + write!(f, "super+")?; + } + if self.modifier.contains(KeyModifiers::HYPER) { + write!(f, "hyper+")?; + } + if self.modifier.contains(KeyModifiers::META) { + write!(f, "meta+")?; + } + match self.code { + KeyCode::Backspace => write!(f, "backspace"), + KeyCode::Enter => write!(f, "enter"), + KeyCode::Left => write!(f, "left"), + KeyCode::Right => write!(f, "right"), + KeyCode::Up => write!(f, "up"), + KeyCode::Down => write!(f, "down"), + KeyCode::Home => write!(f, "home"), + KeyCode::End => write!(f, "end"), + KeyCode::PageUp => write!(f, "pageup"), + KeyCode::PageDown => write!(f, "pagedown"), + KeyCode::Tab => write!(f, "tab"), + KeyCode::BackTab => write!(f, "backtab"), + KeyCode::Delete => write!(f, "delete"), + KeyCode::Insert => write!(f, "insert"), + KeyCode::Char(c) => write!(f, "{c}"), + KeyCode::Null => write!(f, "null"), + KeyCode::Esc => write!(f, "esc"), + _ => write!(f, "unknown"), + } + } +} + +impl From for String { + fn from(value: KeyBinding) -> Self { + value.to_string() + } +} + +impl TryFrom for KeyBinding { + type Error = NymTuiError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl FromStr for KeyBinding { + type Err = NymTuiError; + + fn from_str(s: &str) -> Result { + match s.rsplit_once('+') { + Some((modifiers, value)) => Ok(Self { + code: parse_keycode(value)?, + modifier: parse_modifiers(modifiers)?, + }), + None => Ok(Self { + code: parse_keycode(s)?, + modifier: KeyModifiers::NONE, + }), + } + } +} + +fn parse_keycode(value: &str) -> Result { + Ok(if value.len() == 1 { + KeyCode::Char( + char::from_str(value) + .map_err(|source| NymTuiError::InvalidCharacter { + str: value.to_string(), + source, + })? + .to_ascii_lowercase(), + ) + } else { + SPECIAL_KEYS + .get(value) + .cloned() + .ok_or_else(|| NymTuiError::UnknownKeyBinding { + value: value.to_string(), + })? + }) +} + +fn parse_modifiers(modifiers: &str) -> Result { + modifiers + .split('+') + .try_fold(KeyModifiers::NONE, |modifiers, token| { + KEY_MODIFIERS + .get(token) + .map(|modifier| modifiers | *modifier) + .ok_or_else(|| NymTuiError::UnknownKeyModifier { + value: token.to_string(), + }) + }) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + KeyBinding::from(*key_event).to_string() +} diff --git a/common/nym-tui-common/src/tui/config/mod.rs b/common/nym-tui-common/src/tui/config/mod.rs new file mode 100644 index 0000000000..00c5cd9ca7 --- /dev/null +++ b/common/nym-tui-common/src/tui/config/mod.rs @@ -0,0 +1,69 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +pub mod keybindings; + +pub const DEFAULT_TICK_RATE: Duration = Duration::from_millis(200); + +const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_millis(500); +const DEFAULT_CANCEL_GRACE: Duration = Duration::from_millis(500); +const DEFAULT_ABORT_GRACE: Duration = Duration::from_millis(200); + +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +pub struct TuiConfig { + pub tui: Tui, + + // #[serde(default)] + // pub key_bindings: KeyBindings, + #[serde(default)] + pub debug: Debug, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub struct Debug { + pub debug_mode_enabled: bool, + + #[serde(with = "humantime_serde")] + pub shutdown_grace: Duration, + + #[serde(with = "humantime_serde")] + pub cancel_grace: Duration, + + #[serde(with = "humantime_serde")] + pub abort_grace: Duration, +} + +impl Default for Debug { + fn default() -> Self { + Debug { + debug_mode_enabled: true, + shutdown_grace: DEFAULT_SHUTDOWN_GRACE, + cancel_grace: DEFAULT_CANCEL_GRACE, + abort_grace: DEFAULT_ABORT_GRACE, + } + } +} + +#[derive(Default, Debug, Serialize, Deserialize, Clone, Copy)] +pub struct Tui { + #[serde(default, flatten)] + pub debug: TuiDebug, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub struct TuiDebug { + #[serde(with = "humantime_serde")] + pub tick_rate: Duration, +} + +impl Default for TuiDebug { + fn default() -> Self { + TuiDebug { + tick_rate: DEFAULT_TICK_RATE, + // frame_rate: DEFAULT_FRAME_RATE, + } + } +} diff --git a/common/nym-tui-common/src/tui/dispatcher/mod.rs b/common/nym-tui-common/src/tui/dispatcher/mod.rs new file mode 100644 index 0000000000..62a5810e7e --- /dev/null +++ b/common/nym-tui-common/src/tui/dispatcher/mod.rs @@ -0,0 +1,139 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::action::Action; +use crate::tui::dispatcher::store::State; +use crate::AppAction; +use async_trait::async_trait; +use color_eyre::eyre; +use tokio::sync::mpsc::UnboundedSender; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tokio_stream::StreamExt; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +pub mod store; + +#[derive(Clone)] +pub struct ActionSender(UnboundedSender>); + +impl From>> for ActionSender +where + T: AppAction, +{ + fn from(value: UnboundedSender>) -> Self { + ActionSender(value) + } +} + +impl ActionSender +where + T: AppAction, +{ + pub fn send(&self, action: impl Into>) { + if let Err(unsent) = self.0.send(action.into()) { + error!("failed to send {:?} action to the dispatcher", unsent.0) + } + } +} + +pub type ActionReceiver = UnboundedReceiverStream>; + +pub type StateUpdateSender = UnboundedSender; +pub type StateUpdateReceiver = UnboundedReceiverStream; + +#[async_trait] +pub trait ActionDispatcher { + type Store: State; + type Actions: AppAction; + + async fn handle_app_action( + &mut self, + action: Self::Actions, + store: &mut Self::Store, + ) -> eyre::Result<()>; +} + +pub struct DispatcherLoop { + dispatcher: D, + store: D::Store, + + // to be used with async actions + #[allow(dead_code)] + action_sender: ActionSender, + action_receiver: ActionReceiver, + state_update_sender: StateUpdateSender, + cancellation_token: CancellationToken, +} + +impl DispatcherLoop +where + D: ActionDispatcher + Send + Sync + 'static, + D::Store: Send + Sync + 'static, +{ + pub(crate) fn new( + dispatcher: D, + store: D::Store, + action_sender: impl Into>, + action_receiver: impl Into>, + state_update_sender: StateUpdateSender, + cancellation_token: CancellationToken, + ) -> DispatcherLoop { + DispatcherLoop { + dispatcher, + store, + action_sender: action_sender.into(), + action_receiver: action_receiver.into(), + state_update_sender, + cancellation_token, + } + } + + async fn handle_action(&mut self, action: Option>) -> eyre::Result<()> { + let Some(action) = action else { + warn!("the dispatcher channel has closed! we're probably already in shutdown!"); + // but if we're not, make sure to kick it off... + self.cancellation_token.cancel(); + return Ok(()); + }; + + match action { + Action::Quit => { + debug!("attempting to handle the QUIT action"); + self.cancellation_token.cancel(); + // no need to send any state updates here + return Ok(()); + } + Action::AppDefined(action) => { + debug!("attempting to handle the following action: {:?}", action); + self.dispatcher + .handle_app_action(action, &mut self.store) + .await?; + } + } + + self.state_update_sender.send(self.store.clone())?; + Ok(()) + } + + pub async fn run(&mut self) -> eyre::Result<()> { + info!("starting the dispatcher loop"); + + // issue initial state + self.state_update_sender.send(self.store.clone())?; + + loop { + tokio::select! { + biased; + _ = self.cancellation_token.cancelled() => { + info!("received cancellation token"); + break; + } + maybe_action = self.action_receiver.next() => { + self.handle_action(maybe_action).await? + } + } + } + Ok(()) + } +} diff --git a/common/nym-tui-common/src/tui/dispatcher/store.rs b/common/nym-tui-common/src/tui/dispatcher/store.rs new file mode 100644 index 0000000000..fe74f306dc --- /dev/null +++ b/common/nym-tui-common/src/tui/dispatcher/store.rs @@ -0,0 +1,6 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub trait State: Clone { + // +} diff --git a/common/nym-tui-common/src/tui/handle.rs b/common/nym-tui-common/src/tui/handle.rs new file mode 100644 index 0000000000..41424072c8 --- /dev/null +++ b/common/nym-tui-common/src/tui/handle.rs @@ -0,0 +1,80 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use color_eyre::eyre; +use crossterm::event::EventStream; +use ratatui::backend::CrosstermBackend as Backend; +use ratatui::crossterm::{ + self, cursor, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::ops::{Deref, DerefMut}; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio_stream::Stream; + +pub struct TuiHandle { + pub terminal: ratatui::Terminal>, + pub crossterm_events: EventStream, +} + +impl TuiHandle { + pub fn new() -> Result { + let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?; + let crossterm_events = EventStream::new(); + + Ok(TuiHandle { + terminal, + crossterm_events, + }) + } + + pub fn enter(&self) -> Result<(), eyre::Error> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; + Ok(()) + } + + pub fn exit(&mut self) -> eyre::Result<()> { + if crossterm::terminal::is_raw_mode_enabled()? { + self.terminal.flush()?; + crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } +} + +impl Stream for TuiHandle { + type Item = ::Item; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.crossterm_events).poll_next(cx) + } + + fn size_hint(&self) -> (usize, Option) { + self.crossterm_events.size_hint() + } +} + +impl Deref for TuiHandle { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for TuiHandle { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for TuiHandle { + fn drop(&mut self) { + // well. at this point we can't do much, we'll just go straight into the panic handler + #[allow(clippy::expect_used)] + self.exit().expect("failed to teardown the terminal") + } +} diff --git a/common/nym-tui-common/src/tui/manager.rs b/common/nym-tui-common/src/tui/manager.rs new file mode 100644 index 0000000000..2c6fe5f329 --- /dev/null +++ b/common/nym-tui-common/src/tui/manager.rs @@ -0,0 +1,158 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::action::Action; +use crate::tui::config::TuiConfig; +use crate::tui::dispatcher::{ActionDispatcher, ActionSender, DispatcherLoop}; +use crate::tui::ui::UiEventLoop; +use crate::Component; +use color_eyre::eyre; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; +use tracing::{error, info}; + +pub struct TuiManager { + shutdown_grace: Duration, + cancel_grace: Duration, + abort_grace: Duration, + + task_tracker: TaskTracker, + cancellation_token: CancellationToken, + + dispatcher_handle: JoinHandle>, + ui_event_loop_handle: JoinHandle>, + + action_sender: ActionSender, +} + +impl TuiManager +where + C: Component + Send + Sync + 'static, + C::State: Send + Sync + 'static, + C::Actions: Send + Sync + Clone + 'static, +{ + pub(crate) fn build_new( + config: TuiConfig, + action_dispatcher: D, + initial_state: D::Store, + ) -> eyre::Result> + where + D: ActionDispatcher + Send + Sync + 'static, + { + let task_tracker = TaskTracker::new(); + let cancellation_token = CancellationToken::new(); + + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let (state_tx, state_rx) = mpsc::unbounded_channel(); + + let mut dispatcher_loop = DispatcherLoop::new( + action_dispatcher, + initial_state, + action_tx.clone(), + action_rx, + state_tx, + cancellation_token.clone(), + ); + let mut ui_event_loop = UiEventLoop::::new( + config.tui.debug.tick_rate, + cancellation_token.clone(), + state_rx, + action_tx.clone(), + )?; + + let dispatcher_handle = task_tracker.spawn(async move { dispatcher_loop.run().await }); + let ui_event_loop_handle = task_tracker.spawn(async move { ui_event_loop.run().await }); + + task_tracker.close(); + + Ok(TuiManager { + shutdown_grace: config.debug.shutdown_grace, + cancel_grace: config.debug.cancel_grace, + abort_grace: config.debug.abort_grace, + task_tracker, + cancellation_token, + dispatcher_handle, + ui_event_loop_handle, + action_sender: action_tx.into(), + }) + } + + async fn graceful_shutdown(&self) { + // 1. try to send quit action to handle it the most gracefully + self.action_sender.send(Action::Quit); + if timeout(self.shutdown_grace, self.task_tracker.wait()) + .await + .is_ok() + { + return; + } + error!("timed out while waiting for graceful shutdown"); + + // 2. if that doesn't work, issue cancellation token + self.cancellation_token.cancel(); + if timeout(self.cancel_grace, self.task_tracker.wait()) + .await + .is_ok() + { + return; + } + error!("timed out while attempting to resolve cancellation token shutdown"); + + // 3. finally go with nuclear option and just abort the tasks + self.dispatcher_handle.abort(); + self.ui_event_loop_handle.abort(); + + if timeout(self.abort_grace, self.task_tracker.wait()) + .await + .is_ok() + { + return; + } + + error!("somehow we still failed to shutdown our tasks! we might end up in a dirty state... oh well") + } + + pub(crate) async fn wait_for_exit_or_signal(&self) { + tokio::select! { + _ = self.task_tracker.wait() => { + // user decided to quit with 'normal' action + } + _ = wait_for_signal() => { + self.graceful_shutdown().await + } + } + } +} + +#[cfg(unix)] +#[allow(clippy::expect_used)] +pub async fn wait_for_signal() { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate()).expect("failed to setup SIGTERM channel"); + let mut sigquit = signal(SignalKind::quit()).expect("failed to setup SIGQUIT channel"); + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Received SIGINT"); + }, + _ = sigterm.recv() => { + info!("Received SIGTERM"); + } + _ = sigquit.recv() => { + info!("Received SIGQUIT"); + } + } +} + +#[cfg(not(unix))] +pub async fn wait_for_signal() { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Received SIGINT"); + }, + } +} diff --git a/common/nym-tui-common/src/tui/mod.rs b/common/nym-tui-common/src/tui/mod.rs new file mode 100644 index 0000000000..470390a84b --- /dev/null +++ b/common/nym-tui-common/src/tui/mod.rs @@ -0,0 +1,46 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::handle::TuiHandle; +use color_eyre::eyre; +use tracing::error; + +pub mod action; +pub mod config; +pub(crate) mod dispatcher; +pub mod handle; +pub(crate) mod manager; +pub(crate) mod ui; + +pub fn initialize_panic_handler() -> eyre::Result<()> { + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .display_location_section(true) + .display_env_section(true) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Err(r) = TuiHandle::new().and_then(|mut h| h.exit()) { + error!("Unable to exit Terminal: {:?}", r); + } + + let msg = format!("{}", panic_hook.panic_report(panic_info)); + #[cfg(not(debug_assertions))] + { + eprintln!("{}", msg); // prints color-eyre stack trace to stderr + } + error!("error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(1); + })); + Ok(()) +} diff --git a/common/nym-tui-common/src/tui/ui/components/common/debug_history.rs b/common/nym-tui-common/src/tui/ui/components/common/debug_history.rs new file mode 100644 index 0000000000..7c9292fec8 --- /dev/null +++ b/common/nym-tui-common/src/tui/ui/components/common/debug_history.rs @@ -0,0 +1,70 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::config::keybindings::key_event_to_string; +use crate::tui::dispatcher::ActionSender; +use crate::{AppAction, Component, State}; +use crossterm::event::KeyEvent; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::{Line, Style, Stylize}; +use ratatui::widgets::Block; +use ratatui::Frame; +use std::marker::PhantomData; + +pub struct DebugHistory { + last_tick_key_events: Vec, + + phantom_state: PhantomData, + phantom_action: PhantomData, +} + +impl Component for DebugHistory +where + S: State, + A: AppAction, +{ + type State = S; + type Actions = A; + + fn new(_state: &Self::State, _action_sender: ActionSender) -> Self + where + Self: Sized, + { + DebugHistory { + last_tick_key_events: vec![], + phantom_state: PhantomData, + phantom_action: PhantomData, + } + } + + fn tick(&mut self) -> bool { + let was_empty = self.last_tick_key_events.is_empty(); + self.last_tick_key_events.drain(..); + + !was_empty + } + + fn handle_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> { + self.last_tick_key_events.push(key); + Ok(()) + } + + fn view(&mut self, frame: &mut Frame, rect: Rect) { + frame.render_widget( + Block::default() + .title_top( + Line::from(format!( + "{:?}", + &self + .last_tick_key_events + .iter() + .map(key_event_to_string) + .collect::>() + )) + .alignment(Alignment::Right), + ) + .title_style(Style::default().bold()), + rect, + ); + } +} diff --git a/common/nym-tui-common/src/tui/ui/components/common/logger.rs b/common/nym-tui-common/src/tui/ui/components/common/logger.rs new file mode 100644 index 0000000000..fce15dbf77 --- /dev/null +++ b/common/nym-tui-common/src/tui/ui/components/common/logger.rs @@ -0,0 +1,81 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::config::keybindings::LoggerKeybindings; +use crate::tui::dispatcher::store::State; +use crate::tui::dispatcher::ActionSender; +use crate::tui::ui::components::Component; +use crate::AppAction; +use crossterm::event::KeyEvent; +use ratatui::layout::Rect; +use ratatui::prelude::*; +use ratatui::widgets::Block; +use ratatui::Frame; +use std::marker::PhantomData; +use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget, TuiWidgetState}; + +pub struct Props { + pub keybindings: LoggerKeybindings, +} + +pub struct Logger { + props: Props, + widget_state: TuiWidgetState, + + phantom_state: PhantomData, + phantom_action: PhantomData, +} + +impl Component for Logger +where + S: State, + for<'a> Props: From<&'a S>, + A: AppAction, +{ + type State = S; + type Actions = A; + + fn new(state: &Self::State, _action_sender: ActionSender) -> Self + where + Self: Sized, + { + Logger { + props: Props::from(state), + widget_state: TuiWidgetState::new(), + phantom_state: PhantomData, + phantom_action: PhantomData, + } + } + + fn tick(&mut self) -> bool { + true + } + + fn handle_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> { + if let Some(tui_event) = self.props.keybindings.tui_logger_event(key.into()) { + self.widget_state.transition(tui_event) + } + Ok(()) + } + + fn view(&mut self, frame: &mut Frame, rect: Rect) { + let border = Block::bordered(); + let inner_area = border.inner(rect); + frame.render_widget(border, rect); + + let tui_sm = TuiLoggerSmartWidget::default() + .style_error(Style::default().fg(Color::Red)) + .style_warn(Style::default().fg(Color::Yellow)) + .style_info(Style::default().fg(Color::Green)) + .style_debug(Style::default().fg(Color::Cyan)) + .style_trace(Style::default().fg(Color::Magenta)) + .output_separator(':') + .output_timestamp(Some("%F %H:%M:%S%.3f".to_string())) + .output_level(Some(TuiLoggerLevelOutput::Long)) + .output_target(true) + .output_file(true) + .output_line(true) + .state(&self.widget_state); + frame.render_widget(tui_sm, inner_area); + } +} diff --git a/common/nym-tui-common/src/tui/ui/components/common/mod.rs b/common/nym-tui-common/src/tui/ui/components/common/mod.rs new file mode 100644 index 0000000000..8808c25230 --- /dev/null +++ b/common/nym-tui-common/src/tui/ui/components/common/mod.rs @@ -0,0 +1,12 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod debug_history; + +#[cfg(feature = "logger")] +pub mod logger; + +pub use debug_history::DebugHistory; + +#[cfg(feature = "logger")] +pub use logger::{Logger, Props as LoggerProps}; diff --git a/common/nym-tui-common/src/tui/ui/components/mod.rs b/common/nym-tui-common/src/tui/ui/components/mod.rs new file mode 100644 index 0000000000..123e81763d --- /dev/null +++ b/common/nym-tui-common/src/tui/ui/components/mod.rs @@ -0,0 +1,45 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::dispatcher::ActionSender; +use crate::{AppAction, State}; +use color_eyre::eyre; +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use ratatui::Frame; + +pub mod common; + +// pub trait Props<'a>: From<&'a Self::State> { +// type State: State; +// } + +pub trait Component { + type State: State; + type Actions: AppAction; + + fn new(state: &Self::State, action_sender: ActionSender) -> Self + where + Self: Sized; + + fn update(self, state: &Self::State) -> Self + where + Self: Sized, + { + let _ = state; + self + } + + // returns boolean indicating whether a rerender is needed + // fn tick(&mut self) -> bool; + fn tick(&mut self) -> bool { + false + } + + fn handle_key(&mut self, key: KeyEvent) -> eyre::Result<()> { + let _ = key; + Ok(()) + } + + fn view(&mut self, frame: &mut Frame, rect: Rect); +} diff --git a/common/nym-tui-common/src/tui/ui/mod.rs b/common/nym-tui-common/src/tui/ui/mod.rs new file mode 100644 index 0000000000..4f5abfb059 --- /dev/null +++ b/common/nym-tui-common/src/tui/ui/mod.rs @@ -0,0 +1,154 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::tui::dispatcher::{ActionSender, StateUpdateReceiver}; +use crate::tui::handle::TuiHandle; +use crate::tui::ui::components::Component; +use color_eyre::eyre; +use color_eyre::eyre::eyre; +use crossterm::event::Event; +use humantime_serde::re::humantime; +use std::io; +use std::marker::PhantomData; +use std::time::Duration; +use tokio::time::{timeout, Instant}; +use tokio_stream::StreamExt; +use tokio_util::sync::CancellationToken; +use tracing::{info, trace, warn}; + +pub mod components; + +pub struct UiEventLoop { + tick_rate: Duration, + cancellation_token: CancellationToken, + state_receiver: StateUpdateReceiver, + + // only to be used to construct root 'App' instance + action_sender: ActionSender, + tui_handle: TuiHandle, + + root_component: PhantomData, +} + +impl UiEventLoop +where + C: Component, +{ + pub fn new( + tick_rate: Duration, + cancellation_token: CancellationToken, + state_receiver: impl Into>, + action_sender: impl Into>, + ) -> eyre::Result { + Ok(UiEventLoop { + tick_rate, + cancellation_token, + state_receiver: state_receiver.into(), + action_sender: action_sender.into(), + tui_handle: TuiHandle::new()?, + root_component: PhantomData, + }) + } + + async fn handle_ui_event( + &mut self, + event: Option>, + root_app: &mut C, + ) -> eyre::Result<()> { + let Some(event) = event else { + warn!("the crossterm event channel has closed! we're probably already in shutdown!"); + // but if we're not, make sure to kick it off... + self.cancellation_token.cancel(); + return Ok(()); + }; + + match event? { + Event::FocusGained => {} + Event::FocusLost => {} + Event::Key(key_event) => root_app.handle_key(key_event)?, + Event::Mouse(_) => {} + Event::Paste(_) => {} + Event::Resize(_, _) => {} + } + + Ok(()) + } + + async fn handle_updated_state(&mut self, state: Option, root_app: C) -> C + where + C: Component, + { + let Some(updated_state) = state else { + warn!("the state update channel has closed! we're probably already in shutdown!"); + // but if we're not, make sure to kick it off... + self.cancellation_token.cancel(); + return root_app; + }; + + root_app.update(&updated_state) + } + + pub async fn run(&mut self) -> eyre::Result<()> + where + // this clone shouldn't really be needed... + C::Actions: Clone, + { + info!("starting the ui loop"); + + // wait for initial state... + let initial_state = timeout(Duration::from_secs(1), self.state_receiver.next()) + .await? + .ok_or_else(|| eyre!("did not receive initial state!"))?; + + let mut root_app = C::new(&initial_state, self.action_sender.clone()); + + let mut tick_rate = self.tick_rate; + let mut tick_interval = tokio::time::interval(tick_rate); + self.tui_handle.enter()?; + + let mut draw = true; + + loop { + if draw { + let draw_start = Instant::now(); + + trace!("redrawing the UI"); + self.tui_handle + .draw(|frame| root_app.view(frame, frame.area()))?; + + let taken = humantime::format_duration(draw_start.elapsed()).to_string(); + trace!(time_taken = taken, "UI drawing"); + } + + tokio::select! { + biased; + _ = self.cancellation_token.cancelled() => { + info!("received cancellation token"); + break; + } + maybe_ui_event = self.tui_handle.next() => { + self.handle_ui_event(maybe_ui_event, &mut root_app).await?; + draw = true; + } + state_update = self.state_receiver.next() => { + root_app = self.handle_updated_state(state_update, root_app).await; + // the tick rate has changed + if self.tick_rate != tick_rate { + tick_rate = self.tick_rate; + tick_interval = tokio::time::interval(tick_rate); + } + draw = true; + } + _ = tick_interval.tick() => { + let tick_start = Instant::now(); + draw = root_app.tick(); + + let taken = humantime::format_duration(tick_start.elapsed()).to_string(); + trace!(time_taken = taken, will_redraw = draw, "app tick"); + }, + } + } + + Ok(()) + } +}