commit b1d7add8a304a318f3fa10bec316e46dfd505bc4 Author: Uncle Stretch Date: Thu Nov 14 15:46:38 2024 +0300 initial ghost-eye sketches with basic functionality Signed-off-by: Uncle Stretch diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64bf0eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..745b104 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "ghost-eye" +version = "0.1.0" +edition = "2021" + +[dependencies] +better-panic = "0.3.0" +chrono = "0.4.38" +clap = { version = "4.5.20", features = ["derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles"] } +codec = { version = "3.6.12", package = "parity-scale-codec" } +color-eyre = "0.6.3" +config = "0.14.0" +crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } +derive_builder = "0.20.2" +derive_deref = "1.1.1" +directories = "5.0.1" +font8x8 = "0.3.1" +futures = "0.3.31" +hex = "0.4.3" +human-panic = "2.0.2" +json5 = "0.4.1" +lazy_static = "1.5.0" +libc = "0.2.159" +log = "0.4.22" +once_cell = "1.20.2" +pretty_assertions = "1.4.1" +rand = "0.8.5" +ratatui = { version = "0.28.1", features = ["serde", "macros"] } +reqwest = { version = "0.12.8", features = ["json"] } +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" +signal-hook = "0.3.17" +sp-consensus-babe = "0.40.0" +sp-core = "34.0.0" +sp-runtime = "39.0.2" +strip-ansi-escapes = "0.2.0" +strum = { version = "0.26.3", features = ["derive"] } +tokio = { version = "1.40.0", features = ["full"] } +tokio-util = "0.7.12" +tracing = "0.1.37" +tracing-error = "0.2.0" +tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] } + +[build-dependencies] +anyhow = "1.0.91" +vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..2461afa --- /dev/null +++ b/build.rs @@ -0,0 +1,13 @@ +use anyhow::Result; +use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; + +fn main() -> Result<()> { + let build = BuildBuilder::all_build()?; + let gix = GixBuilder::all_git()?; + let cargo = CargoBuilder::all_cargo()?; + Emitter::default() + .add_instructions(&build)? + .add_instructions(&gix)? + .add_instructions(&cargo)? + .emit() +} diff --git a/config/config.json5 b/config/config.json5 new file mode 100644 index 0000000..1a43d79 --- /dev/null +++ b/config/config.json5 @@ -0,0 +1,16 @@ +{ + "keybindings": { + "Explorer": { + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend", + }, + "Empty": { + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend", + } + } +} diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..e931d0e --- /dev/null +++ b/src/action.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; +use strum::Display; + +use crate::types::{ + block::BlockInfo, + era::EraInfo, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + ClearScreen, + Error(String), + Help, + + SetMode(crate::app::Mode), + //MenuItem(bool), + + GetNodeName, + GetSyncState, + GetGenesisHash, + GetChainName, + GetNodeVersion, + GetPendingExtrinsics, + + GetLatestBlock, + GetFinalizedBlock, + GetActiveEra, + GetEpoch, + GetValidators, + + SetNodeName(Option), + SetSyncState(Option, bool, bool), + SetGenesisHash(Option), + SetChainName(Option), + SetNodeVersion(Option), + + SetLatestBlock(String, BlockInfo), + SetFinalizedBlock(String, BlockInfo), + SetActiveEra(EraInfo), + SetEpoch(u64, u64), + SetValidators(Vec), + SetPendingExtrinsicsLength(usize), +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..beb9a61 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,259 @@ +use color_eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use serde::{Serialize, Deserialize}; +use tokio::sync::mpsc::{UnboundedSender, UnboundedReceiver}; +use std::sync::mpsc::Sender; +use tracing::info; + +use crate::{ + action::Action, + config::Config, + tui::{Event, Tui}, + components::{ + menu::Menu, version::Version, explorer::Explorer, empty::Empty, + health::Health, fps::FpsCounter, Component}, +}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + Explorer(#[serde(skip)] bool), + Empty(#[serde(skip)] bool), +} + +impl Default for Mode { + fn default() -> Self { + Self::Explorer(false) + } +} + +pub struct App { + network_tx: Sender, + action_tx: UnboundedSender, + action_rx: UnboundedReceiver, + frame_rate: f32, + tick_rate: f32, + mouse: bool, + paste: bool, + config: Config, + components: Vec>, + should_quite: bool, + should_suspend: bool, + mode: Mode, + last_tick_key_events: Vec, +} + +impl App { + pub fn new( + network_tx: Sender, + action_tx: UnboundedSender, + action_rx: UnboundedReceiver, + ) -> Result { + Ok(Self { + network_tx, + action_tx, + action_rx, + frame_rate: 4.0, + tick_rate: 60.0, + mouse: false, + paste: false, + config: Config::new()?, + components: vec![ + Box::new(Menu::default()), + Box::new(FpsCounter::default()), + Box::new(Health::default()), + Box::new(Version::default()), + Box::new(Explorer::default()), + Box::new(Empty::default()), + ], + should_quite: false, + should_suspend: false, + mode: Mode::default(), + last_tick_key_events: Vec::new(), + }) + } + + pub fn with_frame_rate(mut self, frame_rate: f32) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn with_tick_rate(mut self, tick_rate: f32) -> Self { + self.tick_rate = tick_rate; + self + } + + pub fn with_mouse(mut self, mouse: bool) -> Self { + self.mouse = mouse; + self + } + + pub fn with_paste(mut self, paste: bool) -> Self { + self.paste = paste; + self + } + + pub async fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()?; + tui.enter()?; + + for component in self.components.iter_mut() { + component.register_action_handler(self.action_tx.clone())?; + } + for component in self.components.iter_mut() { + component.register_config_handler(self.config.clone())?; + } + for component in self.components.iter_mut() { + component.init(tui.size()?)?; + } + + let action_tx = self.action_tx.clone(); + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + tui.enter()?; + } else if self.should_quite { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } + + async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; + let action_tx = self.action_tx.clone(); + match event { + Event::Quit => action_tx.send(Action::Quit)?, + Event::Tick => action_tx.send(Action::Tick)?, + Event::Render => action_tx.send(Action::Render)?, + Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + Event::Key(key) => self.handle_key_event(key)?, + Event::Node => self.trigger_node_events()?, + Event::FastNode => self.trigger_node_fast_events()?, + Event::Runtime => self.trigger_runtime_events()?, + _ => {} + } + + for component in self.components.iter_mut() { + if let Some(action) = component.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } + } + Ok(()) + } + + fn trigger_node_fast_events(&mut self) -> Result<()> { + self.network_tx.send(Action::GetPendingExtrinsics)?; + Ok(()) + } + + fn trigger_node_events(&mut self) -> Result<()> { + self.network_tx.send(Action::GetNodeName)?; + self.network_tx.send(Action::GetSyncState)?; + self.network_tx.send(Action::GetGenesisHash)?; + self.network_tx.send(Action::GetChainName)?; + self.network_tx.send(Action::GetNodeVersion)?; + Ok(()) + } + + fn trigger_runtime_events(&mut self) -> Result<()> { + self.network_tx.send(Action::GetLatestBlock)?; + self.network_tx.send(Action::GetFinalizedBlock)?; + self.network_tx.send(Action::GetActiveEra)?; + self.network_tx.send(Action::GetEpoch)?; + self.network_tx.send(Action::GetValidators)?; + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + let action_tx = self.action_tx.clone(); + let Some(keymap) = self.config.keybindings.get(&self.mode) else { + return Ok(()); + }; + match keymap.get(&vec![key]) { + Some(action) => { + info!("got action: {action:?}"); + action_tx.send(action.clone())?; + } + _ => { + self.last_tick_key_events.push(key); + if let Some(action) = keymap.get(&self.last_tick_key_events) { + info!("got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + } + Ok(()) + } + + fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + match action { + Action::Tick => { self.last_tick_key_events.drain(..); }, + Action::Quit => self.should_quite = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::ClearScreen => tui.terminal.clear()?, + Action::Resize(x, y) => self.handle_resize(tui, x, y)?, + Action::Render => self.render(tui)?, + Action::SetMode(mode) => self.mode = mode, + _ => {} + } + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + self.action_tx.send(action)? + }; + } + } + Ok(()) + } + + fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { + tui.resize(Rect::new(0, 0, w, h))?; + self.render(tui)?; + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + for component in self.components.iter_mut().take(4) { + if let Err(err) = (*component).draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("failed to draw: {:?}", err))); + } + } + + match self.mode { + Mode::Explorer(_) => { + if let Some(component) = self.components.get_mut(4) { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("failed to draw: {:?}", err))); + } + } + }, + _ => { + if let Some(component) = self.components.last_mut() { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("failed to draw: {:?}", err))); + } + } + }, + } + })?; + + Ok(()) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ba0a3ce --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,57 @@ +use clap::Parser; + +use crate::config::{get_config_dir, get_data_dir}; + +#[derive(Parser, Debug)] +#[command(author, version = version(), about)] +pub struct Cli { + /// Tick rate, i.e. number of ticks per second + #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] + pub tick_rate: f32, + + /// Frame rate, i.e. number of frames per second + #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] + pub frame_rate: f32, + + /// RPC Endpoint to the nodes JSON RPC + #[arg(short, long, default_value_t = String::from("http://localhost:9945"))] + pub rpc_endpoint: String, + + /// Request timeout in seconds + #[arg(short = 'i', long, default_value_t = 2)] + pub timeout: u64, + + /// Mouse usage during execution, EXPERIMENTAL AND NOT RECOMENDED + #[arg(short, long)] + pub mouse_needed: bool, + + /// Paste available during execution, EXPERIMENTAL AND NOT RECOMENDED + #[arg(short, long)] + pub paste_needed: bool, +} + +const VERSION_MESSAGE: &str = concat!( + env!("CARGO_PKG_VERSION"), + "-", + env!("VERGEN_GIT_DESCRIBE"), + " (", + env!("VERGEN_BUILD_DATE"), + ")", +); + +fn version() -> String { + let author = clap::crate_authors!(); + + let config_dir_path = get_config_dir().display().to_string(); + let data_dir_path = get_data_dir().display().to_string(); + + format!( + "\ +{VERSION_MESSAGE} + +Authors: {author} + +Config directory: {config_dir_path} +Data directory: {data_dir_path}" + ) +} diff --git a/src/components/empty.rs b/src/components/empty.rs new file mode 100644 index 0000000..4a923dc --- /dev/null +++ b/src/components/empty.rs @@ -0,0 +1,125 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + text::Line, + widgets::{Block, Padding, Paragraph}, + Frame, +}; + +use super::Component; +use crate::{ + action::Action, app::Mode +}; + +#[derive(Debug, Clone, Default)] +pub struct Empty { + is_active: bool, +} + +impl Empty { + fn prepare_inactive_text(&self) -> Vec { + vec![ + Line::from(" ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢐⣤⣼⣿⣿⣿⣿⣿⣿⣷⣶⣦⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣢⣾⣿⣿⣿⣿⣿⣿⣿⣿⣯⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⡟⠛⢻⠉⡉⠍⠁⠁⠀⠈⠙⢻⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠏⢠⢀⡼⡄⠃⠤⠀⠀⠀⠀⠀⡐⠸⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⢰⣸⡎⣀⣷⣤⣶⣶⣶⣦⡀⠀⠈⠓⢿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣇⣤⣯⣿⣿⣿⣿⣿⣿⣿⣭⣯⡆⠀⠀⠘⣿⣿⣿⣿⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡿⣻⣿⣿⣼⠀⢹⣿⣿⣿⣿⡿⠋⠁⠀⠀⠀⢘⣿⠙⠡⢽⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢙⣛⣿⣯⠏⠀⢀⣿⣿⣿⣯⣠⡀⠀⠀⠀⢀⣾⡏⠒⢻⣷⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⡟⢘⣏⣺⣤⣬⣭⣼⣿⣿⣯⡉⢻⣦⣌⣦⣾⣿⣿⡚⠾⠿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⢹⡼⣿⣿⢼⣿⣿⣿⣿⣿⣿⣿⣾⣿⣿⣿⡿⣿⢿⡟⢳⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢳⣿⣧⡞⣻⣩⣽⡽⣿⣿⣿⣿⣿⣿⣿⣿⡟⣠⣿⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡿⣇⣬⣿⣿⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣿⡿⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡛⣿⣄⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢼⡃⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⠁⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠓⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠈⢳⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠿⢿⡟⠻⢿⣿⡷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣍⠓⠲⠤⢤⣄⡀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣇⠀⠈⣿⡏⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠈⠈⢯⡁⠀⠀⠀⠉⠹⠶⢤⣀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣻⠀⢀⠹⣿⡆⠀⢰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢻⣷⣤⣄⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⠴⠚⢩⠀⢸⡄⢹⣿⣦⣸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣷⣤⡄⠀⢀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠴⠋⡀⣀⣰⣿⠀⠄⠹⣾⣿⣿⡿⣿⠀⢠⣤⣀⣴⣤⣤⡴⠶⠶⠿⠿⠛⠛⠋⠉⠉⣠⣿"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⠞⠁⢀⡱⠏⠉⡟⠃⠀⠀⠀⢸⣿⣿⠇⣿⡴⠾⠛⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⡿⠟"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡤⠖⢋⣡⣶⣿⣂⡼⠁⠉⠙⠋⠙⠿⠟⣢⣄⢿⡟⠴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠈⠀⠀"), + Line::from("⠀⠀⠀⢀⣠⠴⠚⠉⠉⠀⠀⠀⠀⠀⣸⡿⠟⠀⠀⠀⠀⠀⠀⠲⣾⡛⣿⣬⡄⠀⠀⠁⠠⣤⠆⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⣠⠞⠉⠀⠀⠀⠀⠀⠀⠀⠀⠤⠚⠉⠀⠀⠀⠀⠀⠀⠀⠀⠺⣿⡟⣿⡟⠀⠀⠂⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠞⠁⠀⠀⠀⠀⠀⠀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢐⡀⡀⣼⣿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⠈⠁⠆⠀⠀⠀"), + Line::from(""), + Line::from(""), + Line::from("Chads only, neither SoyDevs nor Corps allowed"), + Line::from("This page soon will be available, join the future"), + Line::from("https://git.ghostchain.io/ghostchain/ghost-eye"), + ] + } + + fn prepare_active_text(&self) -> Vec { + vec![ + Line::from(" ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠴⠊⠁⠀⠀⠀⠀⠀⠉⠒⠤⡀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠋⠀⠀⠀⢀⣀⣀⣠⣤⣴⣆⣀⠀⠙⡆⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⢋⣀⣤⣶⣯⣯⡶⠯⠭⠔⠚⠛⠂⠀⠀⢷⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⢀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠉⠋⠉⠉⠀⠀⠀⠐⠒⠒⠋⠓⠂⠀⢸⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⣠⠔⠊⠉⠀⠀⠈⠉⠐⠢⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡄⠠⠒⠈⢡⣒⠀⢀⣰⣧⣶⡦⠀⠀⠀⠸⠀⠀⠀⠀"), + Line::from("⠀⣠⠎⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⢢⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢇⠀⠸⠿⠿⠿⡆⢸⠈⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣄⣀⡀⠀⠀⠀⠀⠸⡄⠀⠀⠀⢀⡇⠀⢧⡀⠀⠀⠀⠀⠀⢠⠀⠀⠀⠀"), + Line::from("⠀⠛⣉⣩⣽⠽⠛⣒⡒⠒⠦⠤⢤⣤⣤⣤⢀⡀⠱⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡏⠀⠈⠉⠓⠦⠤⠤⡇⡀⠀⠀⠘⠤⠤⠴⠃⠀⠀⠀⠀⣠⣿⣆⠀⠀⠀"), + Line::from("⠀⡿⢛⣁⣈⠉⠉⠒⠍⠉⠩⠭⠽⠂⠢⠭⢷⠍⠀⢹⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⠥⠤⣀⠀⠀⠀⠀⠀⠘⢷⠀⢀⣴⣸⣿⣠⡷⣄⣄⢠⣾⣿⣿⠋⠑⠲⠤"), + Line::from("⠀⠈⣁⠀⠈⠑⠄⠀⠀⠀⠀⠀⠀⠠⠖⠒⠲⡄⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⢤⡀⠀⠀⠀⠀⣿⣸⣧⣿⣿⣿⣿⡎⢹⣿⣿⡿⠛⠀⠀⠀⠀"), + Line::from("⠟⢩⢿⣛⡛⠳⣖⠲⣖⠒⠒⡶⠖⣲⠿⠿⣖⠦⢤⡤⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢦⡀⠀⠀⠀⠻⣯⠙⠿⠿⠋⠀⢰⣾⠿⠁⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⡻⠿⠿⢧⡿⢀⣿⠖⢲⣧⣼⣿⣿⡯⠎⠀⠀⣇⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢄⠀⠀⠀⠙⠟⣶⣤⣶⣦⡿⠏⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⣦⣄⣉⣐⣂⣉⣤⠿⡿⠀⠀⢻⣻⣝⣒⣂⣈⣠⣴⠃⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⡄⠀⠀⠀⠀⠉⠁⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⢠⠇⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⠸⣀⣀⢀⡠⠇⠀⠀⠀⠀⠀⠀⣸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⠀⠀⢠⢆⣈⢁⡀⠀⠀⠀⠀⠀⠀⠀⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠀⠀⠀⠀⠀⢠⡞⢁⣼⠯⠴⣷⣤⡀⠀⠀⠀⢠⣾⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⣀⠀⠀⠀⢀⡾⣠⣿⣿⣿⣷⡎⣟⣷⠀⣀⣴⣶⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⣼⣧⠀⠀⢸⡇⢿⢻⣿⣿⣿⡇⠉⢙⣄⣟⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⣿⡜⣞⡆⡾⢿⣬⠆⠙⠛⠋⢸⠀⣸⠛⣻⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⡻⣿⢹⣸⡇⠛⢣⡜⣖⠲⣖⠁⣷⢳⣶⢿⣼⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⡇⠙⠙⠯⣿⢠⣼⢿⡾⡴⣿⡀⣽⣿⠟⢃⣼⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from("⠁⠀⠀⠀⡟⡿⢾⣻⣿⣯⣿⡷⠿⡍⠀⢸⠘⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"), + Line::from(""), + Line::from("...if you don't know how to exit Mr.Soyjak"), + Line::from("press ESC, noob noob noob noob noob noob"), + ] + } + + fn set_active(&mut self) -> Result<()> { + self.is_active = true; + Ok(()) + } + + fn unset_active(&mut self) -> Result<()> { + self.is_active = false; + Ok(()) + } +} + +impl Component for Empty { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetMode(Mode::Empty(true)) if !self.is_active => self.set_active()?, + Action::SetMode(Mode::Empty(false)) if self.is_active => self.unset_active()?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let screen = super::screen_layout(area); + + let lines = if self.is_active { self.prepare_active_text() } else { self.prepare_inactive_text() }; + let lines_len = lines.len() as u16; + let padding_top = screen + .as_size() + .height + .saturating_sub(lines_len) / 2; + + let paragraph = Paragraph::new(lines) + .block(Block::bordered().padding(Padding::new(0, 0, padding_top / 2, 0))) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, screen); + Ok(()) + } +} diff --git a/src/components/explorer/block_ticker.rs b/src/components/explorer/block_ticker.rs new file mode 100644 index 0000000..b0b0148 --- /dev/null +++ b/src/components/explorer/block_ticker.rs @@ -0,0 +1,118 @@ +use std::time::Instant; + +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + widgets::{Block, Padding, Paragraph, Wrap}, + Frame +}; + +use super::Component; +use crate::{ + widgets::{BigText, PixelSize}, + action::Action, + palette::StylePalette, +}; + +#[derive(Debug)] +pub struct BlockTicker { + last_block: u32, + last_block_time: Instant, + palette: StylePalette +} + +impl Default for BlockTicker { + fn default() -> Self { + Self::new() + } +} + +impl BlockTicker { + pub fn new() -> Self { + Self { + last_block: 0, + last_block_time: Instant::now(), + palette: StylePalette::default(), + } + } + + fn block_found(&mut self, block: &str) -> Result<()> { + let block = block.trim_start_matches("0x"); + let block = u32::from_str_radix(&block, 16)?; + if self.last_block < block { + self.last_block_time = Instant::now(); + self.last_block = block; + } + Ok(()) + } +} + +impl Component for BlockTicker { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetLatestBlock(_, block_info) => + self.block_found(&block_info.header.number)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [place, _, _] = super::explorer_block_info_layout(area); + + let passed = (Instant::now() - self.last_block_time).as_secs_f64(); + let text = if passed < 60.0 { + format!("{:.1}s", passed) + } else if passed < 3600.0 { + let passed = passed % 3600.0 / 60.0; + format!("{:.1}m", passed) + } else { + let passed = passed % (24.0 * 3600.0) / 3600.0; + format!("{:.1}h", passed) + }; + + let (border_style, border_type) = self.palette.create_border_style(false); + let height = place.as_size().height; + let width = place.as_size().width; + let text_width = text.len() as u16 * 4 + 2; + + if width < text_width || height < 7 { + let paragraph = Paragraph::new(text) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .padding(Padding::new(0, 0, (height - 2) / 2, 0)) + .title("Passed")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + } else { + let big_text = BigText::builder() + .centered() + .pixel_size(PixelSize::Quadrant) + .style(self.palette.create_text_style(false)) + .lines(vec![ + text.into(), + ]) + .build(); + let paragraph = Paragraph::new("") + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .title("Passed")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + + let height_offset = (height - 2) / 2; + let place = place.offset(ratatui::layout::Offset { x: 1, y: height_offset as i32 }) + .intersection(place); + frame.render_widget(big_text, place); + } + Ok(()) + } +} diff --git a/src/components/explorer/current_epoch.rs b/src/components/explorer/current_epoch.rs new file mode 100644 index 0000000..3db463d --- /dev/null +++ b/src/components/explorer/current_epoch.rs @@ -0,0 +1,110 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + text::Line, + widgets::{Block, Padding, Paragraph, Wrap}, + Frame, +}; + +use super::Component; +use crate::{action::Action, palette::StylePalette, widgets::{PixelSize, BigText}}; + +#[derive(Debug, Default)] +pub struct CurrentEpoch { + number: u64, + progress: u64, + palette: StylePalette, +} + +impl CurrentEpoch { + const SECONDS_IN_BLOCK: u64 = 6; + const SESSION_LENGTH: u64 = 2_400; + const SECONDS_IN_DAY: u64 = 86_400; + const SECONDS_IN_HOUR: u64 = 3_600; + + fn update_epoch(&mut self, number: u64, progress: u64) -> Result<()> { + self.number = number; + self.progress = progress; + Ok(()) + } +} + +impl Component for CurrentEpoch { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetEpoch(number, progress) => self.update_epoch(number, progress)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [place, _] = super::explorer_era_info_layout(area); + + let seconds_to_next = Self::SESSION_LENGTH.saturating_sub(self.progress) * Self::SECONDS_IN_BLOCK; + let hours = (seconds_to_next % Self::SECONDS_IN_DAY) / Self::SECONDS_IN_HOUR; + let minutes = (seconds_to_next % Self::SECONDS_IN_HOUR) / 60; + let seconds = seconds_to_next % 60; + + let text = self.number.to_string(); + let big_time = hours > 0; + + let (border_style, border_type) = self.palette.create_border_style(false); + + let height = place.as_size().height; + let width = place.as_size().width; + let text_width = text.len() as u16 * 4 + 2; + + if width < text_width || height < 7 { + let text = vec![ + Line::from(text), + Line::from(format!("{}{} {}{}", + if big_time { hours } else { minutes }, + if big_time { "hrs" } else { "mins" }, + if big_time { minutes } else { seconds }, + if big_time { "mins" } else { "secs" })), + ]; + let paragraph = Paragraph::new(text) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .padding(Padding::new(0, 0, (height - 3) / 2, 0)) + .title("Epoch")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + } else { + let big_text = BigText::builder() + .centered() + .pixel_size(PixelSize::Quadrant) + .style(self.palette.create_text_style(false)) + .lines(vec![ + text.into(), + ]) + .build(); + let paragraph = Paragraph::new("") + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_style(self.palette.create_title_style()) + .title_top(Line::from("Epoch").right_aligned()) + .title_top(Line::from(format!("{}{} {}{}", + if big_time { hours } else { minutes }, + if big_time { "hrs" } else { "mins" }, + if big_time { minutes } else { seconds }, + if big_time { "mins" } else { "secs" })) + .centered())) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + + let height_offset = (height - 2) / 2; + let place = place.offset(ratatui::layout::Offset { x: 1, y: height_offset as i32 }) + .intersection(place); + frame.render_widget(big_text, place); + } + Ok(()) + } +} diff --git a/src/components/explorer/current_era.rs b/src/components/explorer/current_era.rs new file mode 100644 index 0000000..8247122 --- /dev/null +++ b/src/components/explorer/current_era.rs @@ -0,0 +1,125 @@ +use color_eyre::Result; +use std::time::Instant; +use ratatui::{ + layout::{Alignment, Rect}, + text::Line, + widgets::{Block, Padding, Paragraph, Wrap}, + Frame, +}; + +use super::Component; +use crate::{action::Action, palette::StylePalette, types::era::EraInfo, widgets::{PixelSize, BigText}}; + +#[derive(Debug, Default)] +pub struct CurrentEra{ + era: EraInfo, + palette: StylePalette, +} + +impl CurrentEra { + const ERA_OFFSET_IN_SLOTS: u64 = 2_400 * 6; + const ERA_OFFSET_IN_MILLIS: u64 = Self::ERA_OFFSET_IN_SLOTS * 6_000; + + const MILLIS_IN_DAY: u64 = 86_400_000; + const MILLIS_IN_HOUR: u64 = 3_600_000; + const MILLIS_IN_MINUTE: u64 = 60_000; + + fn update_era(&mut self, era_info: EraInfo) -> Result<()> { + self.era = era_info; + Ok(()) + } +} + +impl Component for CurrentEra { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetActiveEra(era_info) => self.update_era(era_info)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [_, place] = super::explorer_era_info_layout(area); + + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time went backwards") + .as_millis() as u64; + + let era_start = self.era.start.unwrap_or_default(); + let era_end = era_start + Self::ERA_OFFSET_IN_MILLIS; + + let (difference, reversed_char) = if era_end > current_time { + (era_end - current_time, "") + } else { + (current_time - era_end, "< ") + }; + + let hours = (difference % Self::MILLIS_IN_DAY) / Self::MILLIS_IN_HOUR; + let minutes = (difference % Self::MILLIS_IN_HOUR) / Self::MILLIS_IN_MINUTE; + let seconds = difference % Self::MILLIS_IN_MINUTE / 1000; + + let text = self.era.index.to_string(); + let big_time = hours > 0; + + let (border_style, border_type) = self.palette.create_border_style(false); + + let height = place.as_size().height; + let width = place.as_size().width; + let text_width = text.len() as u16 * 4 + 2; + + if width < text_width || height < 7 { + let text = vec![ + Line::from(text), + Line::from(format!("{}{}{} {}{}", + reversed_char, + if big_time { hours } else { minutes }, + if big_time { "hrs" } else { "mins" }, + if big_time { minutes } else { seconds }, + if big_time { "mins" } else { "secs" })), + ]; + let paragraph = Paragraph::new(text) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .padding(Padding::new(0, 0, (height - 3) / 2, 0)) + .title("Era")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + } else { + let big_text = BigText::builder() + .centered() + .pixel_size(PixelSize::Quadrant) + .style(self.palette.create_text_style(false)) + .lines(vec![ + text.into(), + ]) + .build(); + let paragraph = Paragraph::new("") + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_style(self.palette.create_title_style()) + .title_top(Line::from("Era").right_aligned()) + .title_top(Line::from(format!("{}{} {}{}", + if big_time { hours } else { minutes }, + if big_time { "hrs" } else { "mins" }, + if big_time { minutes } else { seconds }, + if big_time { "mins" } else { "secs" })) + .centered())) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + + let height_offset = (height - 2) / 2; + let place = place.offset(ratatui::layout::Offset { x: 1, y: height_offset as i32 }) + .intersection(place); + frame.render_widget(big_text, place); + } + Ok(()) + } +} diff --git a/src/components/explorer/explorer_blocks.rs b/src/components/explorer/explorer_blocks.rs new file mode 100644 index 0000000..3420903 --- /dev/null +++ b/src/components/explorer/explorer_blocks.rs @@ -0,0 +1,484 @@ +use std::collections::{HashMap, VecDeque}; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Alignment, Rect}, + prelude::*, + style::Style, + text::Line, + widgets::{Block, BorderType, Paragraph}, + Frame +}; +use sp_consensus_babe::digests::PreDigest; +use sp_runtime::DigestItem; +use codec::Decode; + +use super::Component; +use crate::{action::Action, app::Mode, palette::StylePalette}; + +#[derive(Debug, Clone)] +struct BlockInfo { + block_number: u32, + finalized: bool, + hash: String, +} + +#[derive(Debug, Default)] +pub struct ExplorerBlocks { + blocks: VecDeque, + extrinsics: HashMap>, + logs: HashMap>, + validators: Vec, + palette: StylePalette, + max_block_len: u32, + + is_active: bool, + used_paragraph_index: usize, + used_block_index: Option, + used_ext_index: Option<(String, usize, usize)>, +} + +impl ExplorerBlocks { + const MAX_BLOCKS: usize = 50; + const LENGTH_OF_BLOCK_HASH: u16 = 66; // hash + 0x prefix + const LENGTH_OF_ADDRESS: u16 = 49; + const TOTAL_OFFSETS: u16 = 18; + + fn update_validator_list(&mut self, validators: Vec) -> Result<()> { + self.validators = validators; + Ok(()) + } + + fn get_author_from_digest(&self, logs: Vec) -> Option { + let logs = logs + .iter() + .map_while(|log| { + hex::decode(log.trim_start_matches("0x")) + .ok() + .map(|log_hex| DigestItem::decode(&mut &log_hex[..]).ok()) + }) + .filter_map(|digest| digest) + .collect::>(); + + let maybe_author = match logs.iter().find(|item| matches!(item, DigestItem::PreRuntime(..))) { + Some(DigestItem::PreRuntime(engine, data)) if *engine == [b'B', b'A', b'B', b'E'] => { + match PreDigest::decode(&mut &data[..]) { + Ok(PreDigest::Primary(primary)) => self.validators.get(primary.authority_index as usize), + Ok(PreDigest::SecondaryPlain(secondary)) => self.validators.get(secondary.authority_index as usize), + Ok(PreDigest::SecondaryVRF(secondary)) => self.validators.get(secondary.authority_index as usize), + _ => None, + } + }, + _ => None, + }; + + maybe_author.cloned() + } + + fn update_latest_block_info( + &mut self, + hash: String, + number_hex_str: String, + logs: Vec, + extrinsics: Vec, + ) -> Result<()> { + let number_hex_str = number_hex_str.trim_start_matches("0x"); + let block_number = u32::from_str_radix(&number_hex_str, 16)?; + let front_block_number = if self.blocks.is_empty() { + 0 + } else { + self.blocks.front().unwrap().block_number + }; + + if front_block_number < block_number { + self.blocks.push_front(BlockInfo { + block_number, + finalized: false, + hash: hash.clone(), + }); + + self.extrinsics.insert(hash.clone(), extrinsics); + self.logs.insert(hash, logs); + + let block_length = block_number.checked_ilog10().unwrap_or(0) + 1; + if self.max_block_len < block_length { + self.max_block_len = block_length; + } + + if self.blocks.len() > Self::MAX_BLOCKS { + if let Some(removed_block_info) = self.blocks.pop_back() { + self.extrinsics.remove(&removed_block_info.hash); + self.logs.remove(&removed_block_info.hash); + } + } + } + Ok(()) + } + + fn update_finalized_block_info( + &mut self, + hash: String, + number_hex_str: String, + logs: Vec, + extrinsics: Vec, + ) -> Result<()> { + let number_hex_str = number_hex_str.trim_start_matches("0x"); + let block_number = u32::from_str_radix(&number_hex_str, 16)?; + + if !self.blocks.is_empty() { + for current_block_info in self.blocks.iter_mut() { + if current_block_info.block_number <= block_number { + *current_block_info = BlockInfo { + block_number: current_block_info.block_number, + hash: hash.clone(), + finalized: true, + }; + self.extrinsics.insert(hash.clone(), extrinsics.clone()); + self.logs.insert(hash.clone(), logs.clone()); + } else { + break; + } + } + } + + Ok(()) + } + + fn prepare_block_line_info(&self, current_block: &BlockInfo, width: u16) -> Line { + let block_number_length = self + .max_block_len + .max(current_block.block_number.checked_ilog10().unwrap_or(0) + 1) as usize; + + let free_space = width + .saturating_sub(block_number_length as u16) + .saturating_sub(Self::TOTAL_OFFSETS); + + let author = self + .logs + .get(¤t_block.hash) + .map_or(String::from("..."), |maybe_logs| { + self.get_author_from_digest(maybe_logs.to_vec()) + .map_or(String::from("..."), |maybe_author| maybe_author) + }); + + if free_space < Self::LENGTH_OF_BLOCK_HASH + Self::LENGTH_OF_ADDRESS { + let len_for_author = free_space * Self::LENGTH_OF_ADDRESS / (Self::LENGTH_OF_BLOCK_HASH + Self::LENGTH_OF_ADDRESS); + let len_for_hash = (free_space - len_for_author) / 2; + + let hash_to_print = format!("{}...{}", + ¤t_block.hash[..len_for_hash as usize], + ¤t_block.hash[(Self::LENGTH_OF_BLOCK_HASH - len_for_hash) as usize..]); + + if &author == "..." { + Line::raw(format!("{:^left$}| {} | {:^right$}", + current_block.block_number, + hash_to_print, + author, + left=block_number_length, + right=(len_for_author + 2) as usize)) + } else { + Line::raw(format!("{} | {} | {}", + current_block.block_number, + hash_to_print, + format!("{}...", &author[..(len_for_author) as usize]))) + } + } else { + let total_space_used = block_number_length as u16 + Self::LENGTH_OF_BLOCK_HASH + Self::LENGTH_OF_ADDRESS; + let margin = (width - total_space_used) as usize / 3; + Line::raw(format!("{:^margin$}|{:^margin$}|{:^margin$}", + current_block.block_number, + current_block.hash, + author)) + } + } + + fn prepare_block_lines(&mut self, rect: Rect) -> Vec { + let width = rect.as_size().width; + let total_length = rect.as_size().height - 2; + let mut total_index = 0; + let mut items = Vec::new(); + + let active_style = self.palette.create_text_style(true); + let latest_style = self.palette.create_text_style(false); + let finalized_style = Style::new().fg(self.palette.foreground_hover()); + + for current_block_info in self.blocks.iter() { + if total_length == total_index { break; } + + let style = if let Some(used_block_index) = self.used_block_index { + if total_index as usize == used_block_index { active_style } else { latest_style } + } else { + if current_block_info.finalized { finalized_style } else { latest_style } + }; + + items.push(self.prepare_block_line_info(¤t_block_info, width).style(style)); + total_index += 1; + } + + items + } + + fn prepare_ext_line_info(&self, index: usize, extrinsic: String, width: u16) -> Line { + let index_len = index.checked_ilog10().unwrap_or(0) + 1; + let len_for_ext = width.saturating_sub(index_len as u16 + 17) as usize; + let len_extrinsic_hash = extrinsic.len(); + Line::from(format!("{} MODULE METHOD {}", + index, + format!("{}...{}", &extrinsic[..len_for_ext], &extrinsic[len_extrinsic_hash - len_for_ext..]))) + } + + fn prepare_ext_lines(&mut self, rect: Rect) -> Vec { + let width = rect.as_size().width; + let total_length = rect.as_size().height - 2; + let mut total_index = 0; + let mut items = Vec::new(); + + let normal_style = self.palette.create_text_style(false); + let active_style = self.palette.create_text_style(true); + + if let Some((used_block_hash, _, _)) = &self.used_ext_index { + if let Some(exts) = self.extrinsics.get(used_block_hash) { + for (index, ext) in exts.iter().enumerate() { + if total_length == total_index { break; } + + let style = if let Some((_, _, used_ext_index)) = self.used_ext_index { + if index == used_ext_index { active_style } else { normal_style } + } else { + normal_style + }; + + items.push(self.prepare_ext_line_info(0, ext.to_string(), width).style(style)); + total_index += 1; + } + } + } + + items + } + + fn move_right(&mut self) { + let new_index = self.used_paragraph_index + 1; + if new_index < 2 { + self.used_paragraph_index = new_index; + } + } + + fn move_left(&mut self) { + self.used_paragraph_index = self.used_paragraph_index + .saturating_sub(1); + } + + fn move_down(&mut self) { + if self.used_paragraph_index == 0 { + self.move_down_blocks(); + } else { + self.move_down_extrinsics(); + } + } + + fn move_up(&mut self) { + if self.used_paragraph_index == 0 { + self.move_up_blocks(); + } else { + self.move_up_extrinsics(); + } + } + + fn move_up_extrinsics(&mut self) { + match &self.used_ext_index { + Some((header, block_index, used_index)) => { + let new_index = used_index + 1; + + let maybe_exts = self.extrinsics.get(&*header); + if maybe_exts.is_none() { return } + let exts = maybe_exts.unwrap(); + + let found = exts + .get(new_index) + .is_some(); + + if found && new_index < exts.len() { + self.used_ext_index = + Some(((&*header).clone(), *block_index, new_index)); + } + }, + None => { + self.used_ext_index = self.blocks + .front() + .map(|block| { + self.extrinsics + .get(&block.hash) + .map(|_| (block.hash.clone(), 0, 0)) + }) + .flatten() + } + } + } + + fn move_up_blocks(&mut self) { + match &self.used_block_index { + Some(used_index) => { + let new_index = used_index + 1; + let maybe_new_extrinsic = self.blocks.get(new_index); + if new_index < self.blocks.len() && maybe_new_extrinsic.is_some() { + self.used_block_index = maybe_new_extrinsic.map(|_| new_index); + } + }, + None => { + self.used_block_index = self.blocks + .front() + .map(|_| 0); + } + } + } + + fn move_down_extrinsics(&mut self) { + match &self.used_ext_index { + Some((header, block_index, used_index)) => { + if *used_index == 0 { return } + let new_index = used_index - 1; + + let maybe_exts = self.extrinsics.get(&*header); + if maybe_exts.is_none() { return } + + let found = maybe_exts + .unwrap() + .get(new_index) + .is_some(); + + if found { + self.used_ext_index = + Some(((&*header).clone(), *block_index, new_index)); + } + + }, + None => { + self.used_ext_index = self.blocks + .front() + .map(|block| { + self.extrinsics + .get(&block.hash) + .map(|_| (block.hash.clone(), 0, 0)) + }) + .flatten() + } + } + } + + fn move_down_blocks(&mut self) { + self.used_block_index = match &self.used_block_index { + Some(used_index) => { + if *used_index == 0 { return } + let new_index = used_index - 1; + self.blocks.get(new_index).map(|_| new_index) + }, + None => self.blocks.front().map(|_| 0), + } + } + + fn set_active(&mut self) -> Result<()> { + self.is_active = true; + Ok(()) + } + + fn unset_active(&mut self) -> Result<()> { + self.is_active = false; + Ok(()) + } + + fn prepare_blocks_paragraph( + &mut self, + place: Rect, + border_style: Color, + border_type: BorderType, + ) -> Paragraph { + let title_style = self.palette.create_title_style(); + Paragraph::new(self.prepare_block_lines(place)) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(title_style) + .title("Blocks")) + .alignment(Alignment::Center) + } + + fn prepare_extrinsics_paragraph( + &mut self, + place: Rect, + border_style: Color, + border_type: BorderType, + ) -> Paragraph { + let title_style = self.palette.create_title_style(); + Paragraph::new(self.prepare_ext_lines(place)) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(title_style) + .title("Transactions")) + .alignment(Alignment::Center) + } + + fn prepare_event_paragraph( + &mut self, + _place: Rect, + border_style: Color, + border_type: BorderType, + ) -> Paragraph { + let title_style = self.palette.create_title_style(); + Paragraph::new("") + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(title_style) + .title("Events")) + .alignment(Alignment::Center) + } +} + +impl Component for ExplorerBlocks { + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + match key.code { + KeyCode::Char('k') | KeyCode::Up => self.move_up(), + KeyCode::Char('j') | KeyCode::Down if self.is_active => self.move_down(), + KeyCode::Char('l') | KeyCode::Right if self.is_active => self.move_right(), + KeyCode::Char('h') | KeyCode::Left if self.is_active => self.move_left(), + KeyCode::Esc => { + self.used_block_index = None; + self.used_ext_index = None; + self.used_paragraph_index = 0; + }, + _ => {}, + }; + + Ok(None) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetLatestBlock(hash, block) => self.update_latest_block_info(hash, block.header.number, block.header.digest.logs, block.extrinsics)?, + Action::SetFinalizedBlock(hash, block) => self.update_finalized_block_info(hash, block.header.number, block.header.digest.logs, block.extrinsics)?, + Action::SetValidators(validators) => self.update_validator_list(validators)?, + Action::SetMode(Mode::Explorer(true)) if !self.is_active => self.set_active()?, + Action::SetMode(Mode::Explorer(false)) if self.is_active => self.unset_active()?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [blocks_place, ext_place] = super::explorer_scrollbars_layout(area); + let [_, _, event_place] = super::explorer_layout(area); + + let (border_style_block, border_type_block) = self.palette.create_border_style(self.is_active && self.used_paragraph_index == 0); + let (border_style_extrinsics, border_type_extrinsics) = self.palette.create_border_style(self.is_active && self.used_paragraph_index == 1); + let (border_style_event, border_type_event) = self.palette.create_border_style(self.is_active && self.used_paragraph_index == 2); + + frame.render_widget(self.prepare_blocks_paragraph(blocks_place, border_style_block, border_type_block), blocks_place); + frame.render_widget(self.prepare_extrinsics_paragraph(ext_place, border_style_extrinsics, border_type_extrinsics), ext_place); + frame.render_widget(self.prepare_event_paragraph(event_place, border_style_event, border_type_event), event_place); + Ok(()) + } +} diff --git a/src/components/explorer/extrinsics_chart.rs b/src/components/explorer/extrinsics_chart.rs new file mode 100644 index 0000000..fa30518 --- /dev/null +++ b/src/components/explorer/extrinsics_chart.rs @@ -0,0 +1,83 @@ +use std::collections::VecDeque; +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + text::Line, + widgets::{Bar, BarChart, BarGroup, Block}, + Frame +}; + +use super::Component; +use crate::action::Action; + +#[derive(Debug, Default)] +pub struct ExtrinsicsChart { + extrinsics: VecDeque<(u32, usize)>, +} + +impl ExtrinsicsChart { + const MAX_LEN: usize = 50; + const BAR_WIDTH: usize = 6; + const BAR_GAP: usize = 1; + + fn extrinsics_bar_chart(&self, width: u16) -> BarChart { + let length = (width as usize) / (Self::BAR_WIDTH + Self::BAR_GAP); + + let bars: Vec = self.extrinsics + .iter() + .rev() + .take(length) + .map(|(block_number, ext_len)| self.extrinsic_vertical_bar(block_number, ext_len)) + .collect(); + + BarChart::default() + .data(BarGroup::default().bars(&bars)) + .block(Block::bordered().title_alignment(Alignment::Right).title("Tx. Heat Map")) + .bar_width(8) + .bar_gap(1) + } + + fn extrinsic_vertical_bar(&self, block_number: &u32, ext_len: &usize) -> Bar { + Bar::default() + .value(*ext_len as u64) + .label(Line::from(format!("..{}", block_number % 100))) + .text_value(ext_len.to_string()) + } + + fn update_extrinsics(&mut self, block_number: String, extrinsics_number: usize) -> Result<()> { + let block_number = block_number.trim_start_matches("0x"); + let block_number = u32::from_str_radix(&block_number, 16)?; + + match self.extrinsics.back() { + Some(back) => { + if back.0 < block_number { + self.extrinsics.push_back((block_number, extrinsics_number)); + if self.extrinsics.len() > Self::MAX_LEN { + let _ = self.extrinsics.pop_front(); + } + } + }, + None => { + self.extrinsics.push_back((block_number, extrinsics_number)); + } + }; + + Ok(()) + } +} + +impl Component for ExtrinsicsChart { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetLatestBlock(_, block) => self.update_extrinsics(block.header.number, block.extrinsics.len())?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [_, place] = super::explorer_header_layout(area); + frame.render_widget(self.extrinsics_bar_chart(place.as_size().width), place); + Ok(()) + } +} diff --git a/src/components/explorer/finalized_block.rs b/src/components/explorer/finalized_block.rs new file mode 100644 index 0000000..127f0c5 --- /dev/null +++ b/src/components/explorer/finalized_block.rs @@ -0,0 +1,83 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + widgets::{Block, Padding, Paragraph, Wrap}, + Frame, +}; + +use super::Component; +use crate::{action::Action, palette::StylePalette, widgets::{PixelSize, BigText}}; + +#[derive(Debug, Default)] +pub struct FinalizedBlock { + number: u32, + palette: StylePalette, +} + +impl FinalizedBlock { + fn update_block_number(&mut self, number_hex_str: String) -> Result<()> { + let number_hex_str = number_hex_str.trim_start_matches("0x"); + self.number = u32::from_str_radix(&number_hex_str, 16)?; + Ok(()) + } +} + +impl Component for FinalizedBlock { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetFinalizedBlock(_, block) => self.update_block_number(block.header.number)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [_, _, place] = super::explorer_block_info_layout(area); + + let text = self.number.to_string(); + let (border_style, border_type) = self.palette.create_border_style(false); + + let height = place.as_size().height; + let width = place.as_size().width; + let text_width = text.len() as u16 * 4 + 2; + + if width < text_width || height < 7 { + let paragraph = Paragraph::new(text) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .padding(Padding::new(0, 0, (height - 2) / 2, 0)) + .title("Latest")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + } else { + let big_text = BigText::builder() + .centered() + .pixel_size(PixelSize::Quadrant) + .style(self.palette.create_text_style(false)) + .lines(vec![ + text.into(), + ]) + .build(); + let paragraph = Paragraph::new("") + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .title("Latest")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + + let height_offset = (height - 2) / 2; + let place = place.offset(ratatui::layout::Offset { x: 1, y: height_offset as i32 }) + .intersection(place); + frame.render_widget(big_text, place); + } + Ok(()) + } +} diff --git a/src/components/explorer/latest_block.rs b/src/components/explorer/latest_block.rs new file mode 100644 index 0000000..db8c351 --- /dev/null +++ b/src/components/explorer/latest_block.rs @@ -0,0 +1,83 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + widgets::{Block, Padding, Paragraph, Wrap}, + Frame, +}; + +use super::Component; +use crate::{action::Action, palette::StylePalette, widgets::{PixelSize, BigText}}; + +#[derive(Debug, Default)] +pub struct LatestBlock { + number: u32, + palette: StylePalette +} + +impl LatestBlock { + fn update_block_number(&mut self, number_hex_str: String) -> Result<()> { + let number_hex_str = number_hex_str.trim_start_matches("0x"); + self.number = u32::from_str_radix(&number_hex_str, 16)?; + Ok(()) + } +} + +impl Component for LatestBlock { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetLatestBlock(_, block) => self.update_block_number(block.header.number)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [_, place, _] = super::explorer_block_info_layout(area); + + let text = self.number.to_string(); + let (border_style, border_type) = self.palette.create_border_style(false); + + let height = place.as_size().height; + let width = place.as_size().width; + let text_width = text.len() as u16 * 4 + 2; + + if width < text_width || height < 7 { + let paragraph = Paragraph::new(text) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .padding(Padding::new(0, 0, (height - 2) / 2, 0)) + .title("Latest")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + } else { + let big_text = BigText::builder() + .centered() + .pixel_size(PixelSize::Quadrant) + .style(self.palette.create_text_style(false)) + .lines(vec![ + text.into(), + ]) + .build(); + let paragraph = Paragraph::new("") + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + .title_alignment(Alignment::Right) + .title_style(self.palette.create_title_style()) + .title("Latest")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, place); + + let height_offset = (height - 2) / 2; + let place = place.offset(ratatui::layout::Offset { x: 1, y: height_offset as i32 }) + .intersection(place); + frame.render_widget(big_text, place); + } + Ok(()) + } +} diff --git a/src/components/explorer/mod.rs b/src/components/explorer/mod.rs new file mode 100644 index 0000000..c9cbd23 --- /dev/null +++ b/src/components/explorer/mod.rs @@ -0,0 +1,110 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + Frame, +}; + +use super::Component; +use crate::action::Action; + +mod latest_block; +mod finalized_block; +mod block_ticker; +mod current_era; +mod current_epoch; +mod extrinsics_chart; +mod explorer_blocks; + +use latest_block::LatestBlock; +use finalized_block::FinalizedBlock; +use block_ticker::BlockTicker; +use current_era::CurrentEra; +use current_epoch::CurrentEpoch; +use extrinsics_chart::ExtrinsicsChart; +use explorer_blocks::ExplorerBlocks; + +pub struct Explorer { + components: Vec> +} + +impl Default for Explorer { + fn default() -> Self { + Self { + components: vec![ + Box::new(BlockTicker::default()), + Box::new(LatestBlock::default()), + Box::new(FinalizedBlock::default()), + Box::new(CurrentEra::default()), + Box::new(CurrentEpoch::default()), + Box::new(ExtrinsicsChart::default()), + Box::new(ExplorerBlocks::default()), + ] + } + } +} + +impl Component for Explorer { + fn update(&mut self, action: Action) -> Result> { + for component in self.components.iter_mut() { + component.update(action.clone())?; + } + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let screen = super::screen_layout(area); + for component in self.components.iter_mut() { + component.draw(frame, screen)?; + } + Ok(()) + } +} + +pub fn explorer_layout(area: Rect) -> [Rect; 3] { + Layout::vertical([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]).areas(area) +} + +pub fn explorer_header_layout(area: Rect) -> [Rect; 2] { + let [header, _, _] = explorer_layout(area); + Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]).areas(header) +} + +pub fn explorer_info_layout(area: Rect) -> [Rect; 2] { + let [info, _] = explorer_header_layout(area); + Layout::vertical([ + Constraint::Percentage(100), + Constraint::Percentage(100), + ]).areas(info) +} + +pub fn explorer_block_info_layout(area: Rect) -> [Rect; 3] { + let [blocks, _] = explorer_info_layout(area); + Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]).flex(Flex::SpaceBetween).areas(blocks) +} + +pub fn explorer_era_info_layout(area: Rect) -> [Rect; 2] { + let [_, blocks] = explorer_info_layout(area); + Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]).flex(Flex::SpaceBetween).areas(blocks) +} + +pub fn explorer_scrollbars_layout(area: Rect) -> [Rect; 2] { + let [_, place, _] = explorer_layout(area); + Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]).areas(place) +} diff --git a/src/components/fps.rs b/src/components/fps.rs new file mode 100644 index 0000000..d524d5d --- /dev/null +++ b/src/components/fps.rs @@ -0,0 +1,93 @@ +use std::time::Instant; + +use color_eyre::Result; +use ratatui::{ + layout::Rect, + style::{Style, Stylize}, + text::Span, + widgets::Paragraph, + Frame, +}; + +use super::Component; +use crate::action::Action; + +#[derive(Debug, Clone, PartialEq)] +pub struct FpsCounter { + last_tick_update: Instant, + tick_count: u32, + ticks_per_second: f64, + + last_frame_update: Instant, + frame_count: u32, + frames_per_second: f64, +} + +impl Default for FpsCounter { + fn default() -> Self { + Self::new() + } +} + +impl FpsCounter { + pub fn new() -> Self { + Self { + last_tick_update: Instant::now(), + tick_count: 0, + ticks_per_second: 0.0, + last_frame_update: Instant::now(), + frame_count: 0, + frames_per_second: 0.0, + } + } + + fn app_tick(&mut self) -> Result<()> { + self.tick_count += 1; + let now = Instant::now(); + let elapsed = (now - self.last_tick_update).as_secs_f64(); + if elapsed >= 1.0 { + self.ticks_per_second = self.tick_count as f64 / elapsed; + self.last_tick_update = now; + self.tick_count = 0; + } + Ok(()) + } + + fn render_tick(&mut self) -> Result<()> { + self.frame_count += 1; + let now = Instant::now(); + let elapsed = (now - self.last_frame_update).as_secs_f64(); + if elapsed >= 1.0 { + self.frames_per_second = self.frame_count as f64 / elapsed; + self.last_frame_update = now; + self.frame_count = 0; + } + Ok(()) + } +} + +impl Component for FpsCounter { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::Tick => self.app_tick()?, + Action::Render => self.render_tick()?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [_, place] = super::header_layout(area); + + let message = format!( + "{:.2} ticks/sec | {:.2} FPS", + self.ticks_per_second, + self.frames_per_second + ); + + let span = Span::styled(message, Style::new().dim()); + let paragraph = Paragraph::new(span).right_aligned(); + frame.render_widget(paragraph, place); + Ok(()) + } +} diff --git a/src/components/health.rs b/src/components/health.rs new file mode 100644 index 0000000..1b2246d --- /dev/null +++ b/src/components/health.rs @@ -0,0 +1,111 @@ +use color_eyre::Result; +use ratatui::{ + layout::Rect, + style::{Style, Stylize}, + text::Span, + widgets::Paragraph, + Frame, +}; + +use super::Component; +use crate::{ + action::Action, + widgets::{DotSpinner, OghamCenter, VerticalBlocks} +}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Health { + name: Option, + peers: Option, + is_syncing: bool, + should_have_peers: bool, + tx_pool_length: usize, +} + +impl Default for Health { + fn default() -> Self { + Self::new() + } +} + +impl Health { + pub fn new() -> Self { + Self { + name: None, + peers: None, + is_syncing: true, + should_have_peers: false, + tx_pool_length: 0, + } + } + + fn set_sync_state(&mut self, peers: Option, is_syncing: bool, should_have_peers: bool) -> Result<()> { + self.peers = peers; + self.is_syncing = is_syncing; + self.should_have_peers = should_have_peers; + Ok(()) + } + + fn set_tx_pool_length(&mut self, tx_pool_length: usize) -> Result<()> { + self.tx_pool_length = tx_pool_length; + Ok(()) + } + + fn set_node_name(&mut self, name: Option) -> Result<()> { + self.name = name; + Ok(()) + } + + pub fn is_syncing_as_string(&self) -> String { + if self.is_syncing { + format!("syncing {}", VerticalBlocks::default().to_string()) + } else { + String::from("synced") + } + } + + pub fn peers_as_string(&self) -> String { + if self.peers.is_some() { + self.peers.unwrap().to_string() + } else { + DotSpinner::default().to_string() + } + } + + pub fn name_as_string(&self) -> String { + if self.name.is_some() { + self.name.clone().unwrap() + } else { + OghamCenter::default().to_string() + } + } +} + +impl Component for Health { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetSyncState(peers, is_syncing, should_have_peers) => { + self.set_sync_state(peers, is_syncing, should_have_peers)? + }, + Action::SetNodeName(name) => self.set_node_name(name)?, + Action::SetPendingExtrinsicsLength(length) => self.set_tx_pool_length(length)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [place, _] = super::header_layout(area); + + let message = format!("{:^12} | tx.pool: {:^3} | peers: {:^3} | {:^9}", + self.name_as_string(), + self.tx_pool_length, + self.peers_as_string(), + self.is_syncing_as_string()); + + let span = Span::styled(message, Style::new().dim()); + let paragraph = Paragraph::new(span).left_aligned(); + frame.render_widget(paragraph, place); + Ok(()) + } +} diff --git a/src/components/menu.rs b/src/components/menu.rs new file mode 100644 index 0000000..705732c --- /dev/null +++ b/src/components/menu.rs @@ -0,0 +1,137 @@ +use color_eyre::Result; +use ratatui::{prelude::*, widgets::*}; +use tokio::sync::mpsc::UnboundedSender; +use crossterm::event::{KeyEvent, KeyCode}; + +use super::Component; +use super::palette::StylePalette; +use crate::{action::Action, app::Mode}; + +pub struct Menu { + command_tx: Option>, + items: Vec, + current_item_index: usize, + is_active: bool, + palette: StylePalette, +} + +impl Default for Menu { + fn default() -> Self { + Menu::new() + } +} + +impl Menu { + pub fn new() -> Self { + Self { + command_tx: None, + items: vec![ + String::from("Explorer"), + String::from("Wallet"), + String::from("Prices"), + String::from("Staking"), + String::from("Governance"), + String::from("Operations"), + ], + current_item_index: Default::default(), + is_active: true, + palette: StylePalette::default(), + + } + } + + fn move_current_up(&mut self) -> Result<()> { + self.current_item_index = self + .current_item_index + .saturating_sub(1); + + if let Some(command_tx) = &self.command_tx { + match self.current_item_index { + 0 => command_tx.send(Action::SetMode(Mode::Explorer(false)))?, + _ => command_tx.send(Action::SetMode(Mode::Empty(false)))?, + } + }; + Ok(()) + } + + fn move_current_down(&mut self) -> Result<()> { + let new_current = self.current_item_index + 1; + if new_current < self.items.len() { + self.current_item_index = new_current; + } + + if let Some(command_tx) = &self.command_tx { + match self.current_item_index { + 0 => command_tx.send(Action::SetMode(Mode::Explorer(false)))?, + _ => command_tx.send(Action::SetMode(Mode::Empty(false)))?, + } + }; + Ok(()) + } + + fn set_active(&mut self) -> Result<()> { + self.is_active = true; + if let Some(command_tx) = &self.command_tx { + match self.current_item_index { + 0 => command_tx.send(Action::SetMode(Mode::Explorer(false)))?, + _ => command_tx.send(Action::SetMode(Mode::Empty(false)))?, + } + }; + Ok(()) + } + + fn unset_active(&mut self) -> Result<()> { + self.is_active = false; + if let Some(command_tx) = &self.command_tx { + match self.current_item_index { + 0 => command_tx.send(Action::SetMode(Mode::Explorer(true)))?, + _ => command_tx.send(Action::SetMode(Mode::Empty(true)))?, + } + }; + Ok(()) + } +} + +impl Component for Menu { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.command_tx = Some(tx); + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + match key.code { + KeyCode::Up | KeyCode::Char('k') if self.is_active => self.move_current_up()?, + KeyCode::Down | KeyCode::Char('j') if self.is_active => self.move_current_down()?, + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right if self.is_active => self.unset_active()?, + KeyCode::Esc if !self.is_active => self.set_active()?, + _ => {}, + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [menu, _] = super::menu_layout(area); + let width = menu.as_size().width as usize; + + let mut text = vec![]; + for (position, item) in self.items.iter().enumerate() { + let active = position == self.current_item_index; + let line = Line::raw(format!("{:^width$}", item)) + .style(self.palette.create_text_style(active)) + .centered(); + text.push(line); + } + + let (color, border_type) = self.palette.create_border_style(self.is_active); + let block = Block::bordered() + .border_style(color) + .border_type(border_type); + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, menu); + Ok(()) + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..f564860 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,95 @@ +use color_eyre::Result; +use crossterm::event::{KeyEvent, MouseEvent}; +use ratatui::{ + layout::{Constraint, Layout, Rect, Size}, + Frame, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{palette, action::Action, config::Config, tui::Event}; + +pub mod fps; +pub mod health; +pub mod menu; +pub mod version; +pub mod explorer; +pub mod empty; + +pub trait Component { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + let _ = tx; + Ok(()) + } + + fn register_config_handler(&mut self, config: Config) -> Result<()> { + let _ = config; + Ok(()) + } + + fn handle_events(&mut self, event: Option) -> Result> { + let action = match event { + Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, + _ => None, + }; + Ok(action) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + let _ = key; + Ok(None) + } + + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { + let _ = mouse; + Ok(None) + } + + fn init(&mut self, area: Size) -> Result<()> { + let _ = area; + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + let _ = action; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; +} + +pub fn global_layout(area: Rect) -> [Rect; 2] { + Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + ]).areas(area) +} + +pub fn header_layout(area: Rect) -> [Rect; 2] { + let [header, _] = global_layout(area); + Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]).areas(header) +} + +pub fn main_layout(area: Rect) -> [Rect; 2] { + let [_, main] = global_layout(area); + Layout::horizontal([ + Constraint::Max(30), + Constraint::Fill(1), + ]).areas(main) +} + +pub fn menu_layout(area: Rect) -> [Rect; 2] { + let [menu, _] = main_layout(area); + Layout::vertical([ + Constraint::Min(0), + Constraint::Length(5), + ]).areas(menu) +} + +pub fn screen_layout(area: Rect) -> Rect { + let [_, screen] = main_layout(area); + screen +} diff --git a/src/components/version.rs b/src/components/version.rs new file mode 100644 index 0000000..a754e2c --- /dev/null +++ b/src/components/version.rs @@ -0,0 +1,81 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Alignment, Rect}, + text::Line, + widgets::{Block, Paragraph, Wrap}, + Frame, +}; + +use super::Component; +use crate::{ + action::Action, palette::StylePalette, widgets::OghamCenter +}; + +#[derive(Debug, Clone, Default)] +pub struct Version { + genesis_hash: Option, + node_version: Option, + chain_name: Option, + palette: StylePalette, +} + +impl Version { + fn set_chain_name(&mut self, chain_name: Option) -> Result<()> { + self.chain_name = chain_name; + Ok(()) + } + + fn set_node_version(&mut self, node_version: Option) -> Result<()> { + self.node_version = node_version; + Ok(()) + } + + fn set_genesis_hash(&mut self, genesis_hash: Option) -> Result<()> { + self.genesis_hash = genesis_hash; + Ok(()) + } + + fn prepared_genesis_hash(&self) -> String { + if self.genesis_hash.is_some() { + let genesis_hash = self.genesis_hash.clone().unwrap(); + let len = genesis_hash.len(); + format!("Genesis: {}...{}", &genesis_hash[0..4], &genesis_hash[len-6..]) + } else { + OghamCenter::default().to_string() + } + } +} + +impl Component for Version { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::SetChainName(name) => self.set_chain_name(name)?, + Action::SetNodeVersion(version) => self.set_node_version(version)?, + Action::SetGenesisHash(genesis) => self.set_genesis_hash(genesis)?, + _ => {} + }; + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let [_, version] = super::menu_layout(area); + + let text_style = self.palette.create_text_style(false); + let (border_style, border_type) = self.palette.create_border_style(false); + let text = vec![ + Line::styled(self.chain_name.clone().unwrap_or(OghamCenter::default().to_string()), text_style), + Line::styled(self.node_version.clone().unwrap_or(OghamCenter::default().to_string()), text_style), + Line::styled(self.prepared_genesis_hash(), text_style), + ]; + let paragraph = Paragraph::new(text) + .block(Block::bordered() + .border_style(border_style) + .border_type(border_type) + ) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + frame.render_widget(paragraph, version); + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7543319 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,454 @@ +use std::{collections::HashMap, env, path::PathBuf}; + +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use derive_deref::{Deref, DerefMut}; +use directories::ProjectDirs; +use ratatui::style::{Color, Modifier, Style}; +use serde::{de::Deserializer, Deserialize}; +use tracing::error; + +use crate::{action::Action, app::Mode}; + +const CONFIG: &str = include_str!("../config/config.json5"); + +#[allow(unused)] +#[derive(Clone, Debug, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub data_dir: PathBuf, + #[serde(default)] + pub config_dir: PathBuf, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[allow(unused)] + #[serde(default, flatten)] + pub config: AppConfig, + #[serde(default)] + pub keybindings: KeyBindings, + #[serde(default)] + pub styles: Styles, +} + +lazy_static::lazy_static! { + pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME") + .to_uppercase() + .to_string(); + pub static ref DATA_FOLDER: Option = + env::var(format!("{}_DATA", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from); + pub static ref CONFIG_FOLDER: Option = + env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from); +} + +impl Config { + pub fn new() -> Result { + let default_config: Config = json5::from_str(CONFIG).unwrap(); + let data_dir = get_data_dir(); + let config_dir = get_config_dir(); + + let mut builder = config::Config::builder() + .set_default("data_dir", data_dir.to_str().unwrap())? + .set_default("config_dir", config_dir.to_str().unwrap())?; + + let config_files = [ + ("config.json5", config::FileFormat::Json5), + ("config.json", config::FileFormat::Json), + ("config.yaml", config::FileFormat::Yaml), + ("config.toml", config::FileFormat::Toml), + ("config.ini", config::FileFormat::Ini), + ]; + + let mut found_config = false; + for (file, format) in &config_files { + let source = config::File::from(config_dir.join(file)) + .format(*format) + .required(false); + builder = builder.add_source(source); + if config_dir.join(file).exists() { + found_config = true; + } + } + + if !found_config { + error!("no configuration file found, application may not behave as expected"); + } + + let mut cfg: Self = builder.build()?.try_deserialize()?; + + for (mode, default_bindings) in default_config.keybindings.iter() { + let user_bindings = cfg.keybindings.entry(*mode).or_default(); + for (key, cmd) in default_bindings.iter() { + user_bindings + .entry(key.clone()) + .or_insert_with(|| cmd.clone()); + } + } + + for (mode, default_styles) in default_config.styles.iter() { + let user_styles = cfg.styles.entry(*mode).or_default(); + for (style_key, style) in default_styles.iter() { + user_styles + .entry(style_key.clone()) + .or_insert(*style); + } + } + + Ok(cfg) + } +} + +pub fn get_data_dir() -> PathBuf { + if let Some(data) = DATA_FOLDER.clone() { + data + } else if let Some(proj_dir) = project_directory() { + proj_dir.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + } +} + +pub fn get_config_dir() -> PathBuf { + if let Some(config) = CONFIG_FOLDER.clone() { + config + } else if let Some(proj_dir) = project_directory() { + proj_dir.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + } +} + +fn project_directory() -> Option { + ProjectDirs::from("com", "ghost", env!("CARGO_PKG_NAME")) +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct KeyBindings(pub HashMap, Action>>); + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let keybindings = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_key_event(raw: &str) -> Result { + let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + + loop { + match current { + rest if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest[4..]; + } + rest if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest[5..]; + } + rest if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest[6..]; + } + _ => break, + }; + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + mut modifiers: KeyModifiers, +) -> Result { + let c = match raw { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + }, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + c if c.len() == 1 => { + let mut c = c.chars().next().unwrap(); + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + } + _ => return Err(format!("unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +#[allow(unused)] +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + }, + KeyCode::Char(' ') => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + }, + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + let mut key = modifiers.join("-"); + if !key.is_empty() { + key.push('-'); + } + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> Result, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + let raw = raw.strip_prefix('>').unwrap_or(raw); + raw + } else { + raw + }; + let sequence = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::>(); + + sequence.into_iter().map(parse_key_event).collect() +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct Styles(pub HashMap>); + +impl<'de> Deserialize<'de> for Styles { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let styles = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(str, style)| (str, parse_style(&style))) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(Styles(styles)) + } +} + +pub fn parse_style(line: &str) -> Style { + let (foreground, background) = + line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); + let foreground = process_color_string(foreground); + let background = process_color_string(&background.replace("on ", "")); + + let mut style = Style::default(); + if let Some(fg) = parse_color(&foreground.0) { + style = style.fg(fg); + } + if let Some(bg) = parse_color(&background.0) { + style = style.bg(bg); + } + + style = style.add_modifier(foreground.1 | background.1); + style +} + +fn process_color_string(color_str: &str) -> (String, Modifier) { + let color = color_str + .replace("grey", "gray") + .replace("bright", "") + .replace("bold", "") + .replace("underline", "") + .replace("inverse", ""); + + let mut modifiers = Modifier::empty(); + if color_str.contains("underline") { + modifiers |= Modifier::UNDERLINED; + } + if color_str.contains("bold") { + modifiers |= Modifier::BOLD; + } + if color_str.contains("inverse") { + modifiers |= Modifier::REVERSED; + } + + (color, modifiers) +} + +fn parse_color(s: &str) -> Option { + let s = s.trim_start(); + let s = s.trim_end(); + + if s.contains("bright color") { + let s = s.trim_start_matches("bright "); + let c = s + .trim_start_matches("color") + .parse::() + .unwrap_or_default(); + Some(Color::Indexed(c.wrapping_shl(8))) + } else if s.contains("color") { + let c = s + .trim_start_matches("color") + .parse::() + .unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("gray") { + let c = 232 + + s.trim_start_matches("gray") + .parse::() + .unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("rgb") { + let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; + let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; + let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; + + let c = 16 + red * 36 + green * 6 + blue; + Some(Color::Indexed(c)) + } else if s == "bold black" { + Some(Color::Indexed(8)) + } else if s == "bold red" { + Some(Color::Indexed(9)) + } else if s == "bold green" { + Some(Color::Indexed(10)) + } else if s == "bold yellow" { + Some(Color::Indexed(11)) + } else if s == "bold blue" { + Some(Color::Indexed(12)) + } else if s == "bold magenta" { + Some(Color::Indexed(13)) + } else if s == "bold cyan" { + Some(Color::Indexed(14)) + } else if s == "bold white" { + Some(Color::Indexed(15)) + } else if s == "black" { + Some(Color::Indexed(0)) + } else if s == "red" { + Some(Color::Indexed(1)) + } else if s == "green" { + Some(Color::Indexed(2)) + } else if s == "yellow" { + Some(Color::Indexed(3)) + } else if s == "blue" { + Some(Color::Indexed(4)) + } else if s == "magenta" { + Some(Color::Indexed(5)) + } else if s == "cyan" { + Some(Color::Indexed(6)) + } else if s == "white" { + Some(Color::Indexed(7)) + } else { + None + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..eb041d3 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,67 @@ +use std::env; + +use color_eyre::Result; +use tracing::error; + +pub fn init() -> Result<()> { + let (_panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!( + "This is a bug. Consider reporting it at {}", + env!("CARGO_PKG_REPOSITORY") + )) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() { + if let Err(r) = t.exit() { + error!("Unable to exit Terminal: {:?}", r); + } + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, metadata, print_msg}; + let metadata = metadata!(); + let file_path = handle_dump(&metadata, panic_info); + print_msg(file_path, &metadata) + .expect("human-panic: printing error message to console failed"); + eprint!("{}", _panic_hook.panic_report(panic_info)); + } + + #[cfg(debug_assertions)] + { + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) +} + +#[macro_export] +macro_rules! trace_dbg { + (target: $target:expr, level: $level:expr, $ex:expr) => {{ + match $ex { + value => { + tracing::event!(target: $target, $level, ?value, stringify!($ex)); + value + } + } + }}; + (level: $level:expr, $ex:expr) => { + trace_dbg!(target: module_path!(), level: $level, $ex) + }; + (target: $target:expr, $ex:expr) => { + trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) + }; + ($ex:expr) => { + trace_dbg!(level: tracing::Level::DEBUG, $ex) + }; +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..4fed622 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,39 @@ +use color_eyre::Result; +use tracing_error::ErrorLayer; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +use crate::config; + +lazy_static::lazy_static! { + pub static ref LOG_ENV: String = format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()); + pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); +} + +pub fn init() -> Result<()> { + let directory = config::get_data_dir(); + std::fs::create_dir_all(directory.clone())?; + + let log_path = directory.join(LOG_FILE.clone()); + let log_file = std::fs::File::create(log_path)?; + + let env_filter = EnvFilter::builder() + .with_default_directive(tracing::Level::INFO.into()); + let env_filter = env_filter + .try_from_env() + .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; + + let file_subscriber = fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false) + .with_filter(env_filter); + + tracing_subscriber::registry() + .with(file_subscriber) + .with(ErrorLayer::default()) + .try_init()?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2dec150 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,53 @@ +use clap::Parser; +use color_eyre::Result; + +mod action; +mod app; +mod cli; +mod components; +mod config; +mod errors; +mod logging; +mod tui; +mod network; +mod widgets; +mod types; +mod palette; + +#[tokio::main] +async fn start_tokio( + io_rx: std::sync::mpsc::Receiver, + network: &mut network::Network, +) { + while let Ok(io_event) = io_rx.recv() { + let _ = network.handle_network_event(io_event).await; + } +} + +#[tokio::main] +async fn main() -> Result<()> { + crate::errors::init()?; + crate::logging::init()?; + + let args = cli::Cli::parse(); + let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel(); + let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel(); + + let cloned_action_tx = action_tx.clone(); + std::thread::spawn(move || { + let mut network = network::Network::new(cloned_action_tx) + .with_url(&args.rpc_endpoint) + .with_timeout(args.timeout); + start_tokio(sync_io_rx, &mut network); + }); + + app::App::new(sync_io_tx, action_tx, action_rx)? + .with_frame_rate(args.frame_rate) + .with_tick_rate(args.tick_rate) + .with_mouse(args.mouse_needed) + .with_paste(args.paste_needed) + .run() + .await?; + + Ok(()) +} diff --git a/src/network/active_era.rs b/src/network/active_era.rs new file mode 100644 index 0000000..8c730c4 --- /dev/null +++ b/src/network/active_era.rs @@ -0,0 +1,33 @@ +use color_eyre::Result; +use codec::Decode; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::rpc_params; +use crate::types::{era::EraInfo, storage::GhostStorage}; + +#[derive(Debug)] +pub struct ActiveEraRequest<'a>(pub GhostRequest<'a>); +impl<'a> ActiveEraRequest<'a> { + pub async fn send(self) -> Result<()> { + let storage_key = GhostStorage::new() + .with_module("Staking") + .with_method("ActiveEra") + .build_storage_key(); + + let result_hex = self + .0 + .send::("state_getStorage", rpc_params![storage_key]) + .await + .map(|r| { + hex::decode(r.result.trim_start_matches("0x")).or::>(Ok(vec![])) + }) + .unwrap() + .unwrap(); + + let active_era = EraInfo::decode(&mut &result_hex[..])?; + self.0.action_tx.send(Action::SetActiveEra(active_era))?; + + Ok(()) + } +} diff --git a/src/network/chain_name.rs b/src/network/chain_name.rs new file mode 100644 index 0000000..0148f41 --- /dev/null +++ b/src/network/chain_name.rs @@ -0,0 +1,20 @@ +use color_eyre::Result; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::types::params::RpcParams; + +#[derive(Debug)] +pub struct ChainNameRequest<'a>(pub GhostRequest<'a>); +impl<'a> ChainNameRequest<'a> { + pub async fn send(self) -> Result<()> { + let chain_name = self + .0 + .send::("system_chain", RpcParams::new()) + .await + .ok() + .map(|response| response.result); + self.0.action_tx.send(Action::SetChainName(chain_name))?; + Ok(()) + } +} diff --git a/src/network/current_epoch.rs b/src/network/current_epoch.rs new file mode 100644 index 0000000..e719299 --- /dev/null +++ b/src/network/current_epoch.rs @@ -0,0 +1,41 @@ +use color_eyre::Result; +use codec::Decode; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::rpc_params; +use crate::types::storage::GhostStorage; + +#[derive(Debug)] +pub struct CurrentEpochRequest<'a>(pub GhostRequest<'a>); +impl<'a> CurrentEpochRequest<'a> { + async fn do_request(&self, module: &str, method: &str) -> Result { + let storage_key = GhostStorage::new() + .with_module(module) + .with_method(method) + .build_storage_key(); + + let result_hex = self + .0 + .send::("state_getStorage", rpc_params![storage_key]) + .await + .map(|r| { + hex::decode(r.result.trim_start_matches("0x")).or::>(Ok(vec![])) + }) + .unwrap() + .unwrap(); + let value = u64::decode(&mut &result_hex[..])?; + Ok(value) + } + + pub async fn send(self) -> Result<()> { + let current_slot = self.do_request("Babe", "CurrentSlot").await?; + let epoch_index = self.do_request("Babe", "EpochIndex").await?; + let genesis_slot = self.do_request("Babe", "GenesisSlot").await?; + + let epoch_start_slot = epoch_index * 2_400 + genesis_slot; + let progress = current_slot.saturating_sub(epoch_start_slot); + self.0.action_tx.send(Action::SetEpoch(epoch_index, progress))?; + Ok(()) + } +} diff --git a/src/network/finalized_block.rs b/src/network/finalized_block.rs new file mode 100644 index 0000000..c0d7e37 --- /dev/null +++ b/src/network/finalized_block.rs @@ -0,0 +1,35 @@ +use color_eyre::Result; +use serde::Deserialize; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::rpc_params; +use crate::types::block::BlockInfo; +use crate::types::params::RpcParams; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LatestBlockResponse { + pub block: BlockInfo, + // justifications: Option +} + +#[derive(Debug)] +pub struct FinalizedBlockRequest<'a>(pub GhostRequest<'a>); +impl<'a> FinalizedBlockRequest<'a> { + pub async fn send(self) -> Result<()> { + let finalized_head = self + .0 + .send::("chain_getFinalizedHead", RpcParams::new()) + .await + .map_or(String::new(), |response| response.result); + let finalized_block = self + .0 + .send::("chain_getBlock", rpc_params![finalized_head.clone()]) + .await + .map_or(BlockInfo::default(), |response| response.result.block); + self.0.action_tx.send(Action::SetFinalizedBlock(finalized_head, finalized_block))?; + + Ok(()) + } +} diff --git a/src/network/genesis_hash.rs b/src/network/genesis_hash.rs new file mode 100644 index 0000000..5cea705 --- /dev/null +++ b/src/network/genesis_hash.rs @@ -0,0 +1,18 @@ +use color_eyre::Result; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::rpc_params; + +#[derive(Debug)] +pub struct GenesisHashRequest<'a>(pub GhostRequest<'a>); +impl<'a> GenesisHashRequest<'a> { + pub async fn send(self) -> Result<()> { + let genesis_hash = self.0.send::("chain_getBlockHash", rpc_params!["0"]) + .await + .ok() + .map(|response| response.result); + self.0.action_tx.send(Action::SetGenesisHash(genesis_hash))?; + Ok(()) + } +} diff --git a/src/network/health_check.rs b/src/network/health_check.rs new file mode 100644 index 0000000..4822ef8 --- /dev/null +++ b/src/network/health_check.rs @@ -0,0 +1,37 @@ +use color_eyre::Result; +use serde::Deserialize; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::types::params::RpcParams; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HealthCheckResponse { + pub peers: u32, + pub is_syncing: bool, + pub should_have_peers: bool +} + +#[derive(Debug)] +pub struct HealthCheckRequest<'a>(pub GhostRequest<'a>); +impl<'a> HealthCheckRequest<'a> { + pub async fn send(self) -> Result<()> { + let (peers, is_syncing, should_have_peers) = self + .0 + .send::("system_health", RpcParams::new()) + .await + .ok() + .map_or((None, false, false), |response| ( + Some(response.result.peers), + response.result.is_syncing, + response.result.should_have_peers, + )); + + self.0.action_tx.send(Action::SetSyncState( + peers, + is_syncing, + should_have_peers))?; + Ok(()) + } +} diff --git a/src/network/latest_block.rs b/src/network/latest_block.rs new file mode 100644 index 0000000..e9f777a --- /dev/null +++ b/src/network/latest_block.rs @@ -0,0 +1,35 @@ +use color_eyre::Result; +use serde::Deserialize; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::rpc_params; +use crate::types::block::BlockInfo; +use crate::types::params::RpcParams; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LatestBlockResponse { + pub block: BlockInfo, + // justifications: Option +} + +#[derive(Debug)] +pub struct LatestBlockRequest<'a>(pub GhostRequest<'a>); +impl<'a> LatestBlockRequest<'a> { + pub async fn send(self) -> Result<()> { + let latest_head = self + .0 + .send::("chain_getBlockHash", RpcParams::new()) + .await + .map_or(String::new(), |response| response.result); + let latest_block = self + .0 + .send::("chain_getBlock", rpc_params![latest_head.clone()]) + .await + .map_or(BlockInfo::default(), |response| response.result.block); + self.0.action_tx.send(Action::SetLatestBlock(latest_head, latest_block))?; + + Ok(()) + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..325f839 --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use once_cell::sync::Lazy; +use reqwest::Client; +use color_eyre::Result; +use rand::RngCore; +use serde::{Deserialize, de::DeserializeOwned}; + +use crate::{action::Action, types::params::RpcParams}; + +mod active_era; +mod health_check; +mod node_name; +mod genesis_hash; +mod chain_name; +mod node_version; +mod latest_block; +mod finalized_block; +mod current_epoch; +mod validators; +mod tx_pool; + +pub use active_era::ActiveEraRequest; +pub use health_check::HealthCheckRequest; +pub use node_name::NodeNameRequest; +pub use genesis_hash::GenesisHashRequest; +pub use chain_name::ChainNameRequest; +pub use node_version::NodeVersionRequest; +pub use latest_block::LatestBlockRequest; +pub use finalized_block::FinalizedBlockRequest; +pub use current_epoch::CurrentEpochRequest; +pub use validators::ValidatorsRequest; +pub use tx_pool::TxPoolRequest; + +static CLIENT: Lazy> = Lazy::new(|| Arc::new(Client::new())); +const DEFAULT_URL: &str = "http://localhost:9945"; + +pub type AppActionSender = UnboundedSender; + +#[derive(Debug, Deserialize)] +pub struct GhostResponse { + result: ResponseType, +} + +#[derive(Default)] +struct GhostRequestBuilder<'a> { + action_tx: Option, + id: u32, + url: &'a str, + timeout: std::time::Duration, +} + +impl<'a> GhostRequestBuilder<'a> { + pub fn with_id(mut self, id: u32) -> Self { + self.id = id; + self + } + + pub fn with_url(mut self, url: &'a str) -> Self { + self.url = url; + self + } + + pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn with_action_tx(mut self, action_tx: AppActionSender) -> Self { + self.action_tx = Some(action_tx); + self + } + + pub fn build(self) -> GhostRequest<'a> { + GhostRequest { + action_tx: self.action_tx.expect("channel sender should exist"), + id: self.id, + url: self.url, + timeout: self.timeout, + } + } +} + +#[derive(Debug)] +pub struct GhostRequest<'a> { + action_tx: AppActionSender, + id: u32, + url: &'a str, + timeout: std::time::Duration, +} + +impl<'a> GhostRequest<'a> { + pub async fn send( + &self, + method: &str, + params: RpcParams, + ) -> Result> + where + ResponseType: DeserializeOwned, + { + Ok(CLIENT + .post(self.url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(format!("{{\"id\":{},\"jsonrpc\":\"2.0\",\"method\":{:?},\"params\":{}}}", + self.id, method, params.build())) + .timeout(self.timeout) + .send() + .await? + .json::>() + .await? + ) + } +} + +pub struct Network<'a> { + action_tx: AppActionSender, + timeout: std::time::Duration, + internal_randomness: rand::rngs::ThreadRng, + url: &'a str, +} + +impl<'a> Network<'a> { + pub fn new(action_tx: AppActionSender) -> Self { + Self { + action_tx, + timeout: Default::default(), + internal_randomness: rand::thread_rng(), + url: DEFAULT_URL, + } + } + + pub fn with_url(mut self, url: &'a str) -> Self { + self.url = url; + self + } + + pub fn with_timeout(mut self, timeout: u64) -> Self { + self.timeout = std::time::Duration::from_secs(timeout); + self + } + + pub async fn handle_network_event(&mut self, io_event: Action) -> Result<()> { + let request = GhostRequestBuilder::default() + .with_action_tx(self.action_tx.clone()) + .with_id(self.internal_randomness.next_u32()) + .with_url(self.url) + .with_timeout(self.timeout) + .build(); + + match io_event { + Action::GetSyncState => HealthCheckRequest(request).send().await, + Action::GetNodeName => NodeNameRequest(request).send().await, + Action::GetGenesisHash => GenesisHashRequest(request).send().await, + Action::GetChainName => ChainNameRequest(request).send().await, + Action::GetNodeVersion => NodeVersionRequest(request).send().await, + Action::GetLatestBlock => LatestBlockRequest(request).send().await, + Action::GetFinalizedBlock => FinalizedBlockRequest(request).send().await, + Action::GetActiveEra => ActiveEraRequest(request).send().await, + Action::GetEpoch => CurrentEpochRequest(request).send().await, + Action::GetValidators => ValidatorsRequest(request).send().await, + Action::GetPendingExtrinsics => TxPoolRequest(request).send().await, + _ => Ok(()) + } + } +} diff --git a/src/network/node_name.rs b/src/network/node_name.rs new file mode 100644 index 0000000..b0ff924 --- /dev/null +++ b/src/network/node_name.rs @@ -0,0 +1,20 @@ +use color_eyre::Result; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::types::params::RpcParams; + +#[derive(Debug)] +pub struct NodeNameRequest<'a>(pub GhostRequest<'a>); +impl<'a> NodeNameRequest<'a> { + pub async fn send(self) -> Result<()> { + let name = self + .0 + .send::("system_name", RpcParams::new()) + .await + .ok() + .map(|response| response.result); + self.0.action_tx.send(Action::SetNodeName(name))?; + Ok(()) + } +} diff --git a/src/network/node_version.rs b/src/network/node_version.rs new file mode 100644 index 0000000..e983e51 --- /dev/null +++ b/src/network/node_version.rs @@ -0,0 +1,20 @@ +use color_eyre::Result; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::types::params::RpcParams; + +#[derive(Debug)] +pub struct NodeVersionRequest<'a>(pub GhostRequest<'a>); +impl<'a> NodeVersionRequest<'a> { + pub async fn send(self) -> Result<()> { + let version = self + .0 + .send::("system_version", RpcParams::new()) + .await + .ok() + .map(|response| response.result); + self.0.action_tx.send(Action::SetNodeVersion(version))?; + Ok(()) + } +} diff --git a/src/network/runtime_version.rs b/src/network/runtime_version.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/network/tx_pool.rs b/src/network/tx_pool.rs new file mode 100644 index 0000000..ec6f749 --- /dev/null +++ b/src/network/tx_pool.rs @@ -0,0 +1,21 @@ +use color_eyre::Result; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::types::params::RpcParams; + +#[derive(Debug)] +pub struct TxPoolRequest<'a>(pub GhostRequest<'a>); +impl<'a> TxPoolRequest<'a> { + pub async fn send(self) -> Result<()> { + let tx_pool = self + .0 + .send::>("author_pendingExtrinsics", RpcParams::new()) + .await + .ok() + .map(|response| response.result) + .unwrap_or_default(); + self.0.action_tx.send(Action::SetPendingExtrinsicsLength(tx_pool.len()))?; + Ok(()) + } +} diff --git a/src/network/validators.rs b/src/network/validators.rs new file mode 100644 index 0000000..0328ff9 --- /dev/null +++ b/src/network/validators.rs @@ -0,0 +1,37 @@ +use color_eyre::Result; +use codec::Decode; +use sp_core::crypto::{AccountId32, Ss58AddressFormat, Ss58Codec}; + +use crate::network::GhostRequest; +use crate::action::Action; +use crate::rpc_params; +use crate::types::storage::GhostStorage; + +#[derive(Debug)] +pub struct ValidatorsRequest<'a>(pub GhostRequest<'a>); +impl<'a> ValidatorsRequest<'a> { + pub async fn send(self) -> Result<()> { + let storage_key = GhostStorage::new() + .with_module("Session") + .with_method("Validators") + .build_storage_key(); + + let result_hex = self.0.send::("state_getStorage", rpc_params![storage_key]) + .await + .map(|response| { + hex::decode(response.result.trim_start_matches("0x")) + .ok() + .unwrap_or_default() + }) + .unwrap(); + let validators = >::decode(&mut &result_hex[..]) + .ok() + .unwrap_or_default() + .iter() + .map(|v| v.to_ss58check_with_version(Ss58AddressFormat::custom(1996))) + .collect::>(); + + self.0.action_tx.send(Action::SetValidators(validators))?; + Ok(()) + } +} diff --git a/src/palette.rs b/src/palette.rs new file mode 100644 index 0000000..a3498e0 --- /dev/null +++ b/src/palette.rs @@ -0,0 +1,103 @@ +use ratatui::style::{Style, Color, Modifier}; +use ratatui::widgets::block::BorderType; + +#[derive(Debug, Clone)] +pub struct StylePalette { + background: Option, + foreground: Option, + modifiers: Vec, + + background_hover: Option, + foreground_hover: Option, + modifiers_hover: Vec, + + border_color: Option, + title_color: Option, + border_type: BorderType, + + border_color_hover: Option, + //title_color_hover: Option, + border_type_hover: BorderType, +} + +impl Default for StylePalette { + fn default() -> Self { + Self::new() + } +} + +impl StylePalette { + // TODO: make read from the config by default + pub fn new() -> Self { + Self { + background: None, + foreground: None, + modifiers: Vec::new(), + + background_hover: Some(Color::Blue), + foreground_hover: Some(Color::Yellow), + modifiers_hover: vec![ + Modifier::ITALIC, + Modifier::BOLD, + ], + + border_color: Some(Color::Blue), + title_color: Some(Color::Blue), + border_type: BorderType::Plain, + + border_color_hover: Some(Color::Blue), + //title_color_hover: Some(Color::Blue), + border_type_hover: BorderType::Double, + } + } + + pub fn foreground_hover(&self) -> Color { + self.foreground_hover.unwrap_or_default() + } + + pub fn create_text_style(&mut self, active: bool) -> Style { + if active { + self.create_text_style_hover() + } else { + self.create_text_style_normal() + } + } + + pub fn create_border_style(&self, active: bool) -> (Color, BorderType) { + if active { + ( + self.border_color_hover.unwrap_or_default(), + self.border_type_hover, + ) + } else { + ( + self.border_color.unwrap_or_default(), + self.border_type, + ) + } + } + + pub fn create_title_style(&mut self) -> Style { + Style::default().fg(self.title_color.unwrap_or_default()) + } + + fn create_text_style_normal(&self) -> Style { + let mut style = Style::default() + .fg(self.foreground.unwrap_or_default()) + .bg(self.background.unwrap_or_default()); + for modifier in self.modifiers.iter() { + style = style.add_modifier(*modifier); + } + style + } + + fn create_text_style_hover(&self) -> Style { + let mut style = Style::default() + .fg(self.foreground_hover.unwrap_or_default()) + .bg(self.background_hover.unwrap_or_default()); + for modifier in self.modifiers_hover.iter() { + style = style.add_modifier(*modifier); + } + style + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..843cbd8 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,220 @@ +use std::{ + io::{stdout, Stdout}, + ops::{Deref, DerefMut}, + time::Duration, +}; + +use color_eyre::Result; +use crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, + EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyEvent, + KeyEventKind, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Serialize, Deserialize}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, + time::interval, +}; +use tokio_util::sync::CancellationToken; +use tracing::error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), + Node, + FastNode, + Runtime, +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, + pub mouse: bool, + pub paste: bool, +} + +impl Tui { + pub fn new() -> Result { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + Ok(Self { + terminal: ratatui::Terminal::new(Backend::new(stdout()))?, + task: tokio::spawn(async {}), + cancellation_token: CancellationToken::new(), + event_rx, + event_tx, + frame_rate: 60.0, + tick_rate: 4.0, + mouse: false, + paste: false, + }) + } + + pub fn start(&mut self) { + self.cancel(); + self.cancellation_token = CancellationToken::new(); + + let event_loop = Self::event_loop( + self.event_tx.clone(), + self.cancellation_token.clone(), + self.tick_rate, + self.frame_rate, + ); + self.task = tokio::spawn(async { + event_loop.await; + }); + } + + async fn event_loop( + event_tx: UnboundedSender, + cancellation_token: CancellationToken, + tick_rate: f64, + frame_rate: f64, + ) { + let mut event_stream = EventStream::new(); + let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); + let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); + + let mut normal_node_interval = interval(Duration::from_secs_f64(9.0)); + let mut fast_node_interval = interval(Duration::from_secs_f64(9.0)); + let mut runtime_interval = interval(Duration::from_secs_f64(1.69)); + + event_tx + .send(Event::Init) + .expect("failed to send init event"); + + loop { + let event = tokio::select! { + _ = cancellation_token.cancelled() => { + break; + }, + _ = tick_interval.tick() => Event::Tick, + _ = render_interval.tick() => Event::Render, + _ = normal_node_interval.tick() => Event::Node, + _ = fast_node_interval.tick() => Event::FastNode, + _ = runtime_interval.tick() => Event::Runtime, + crossterm_event = event_stream.next().fuse() => match crossterm_event { + Some(Ok(event)) => match event { + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), + CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), + CrosstermEvent::Resize(x, y) => Event::Resize(x, y), + CrosstermEvent::FocusLost => Event::FocusLost, + CrosstermEvent::FocusGained => Event::FocusGained, + CrosstermEvent::Paste(s) => Event::Paste(s), + _ => continue, + } + Some(Err(_)) => Event::Error, + None => break, + }, + }; + + if event_tx.send(event).is_err() { + break; + } + } + cancellation_token.cancel(); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + error!("failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(stdout(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(stdout(), EnableBracketedPaste)?; + } + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + if self.paste { + crossterm::execute!(stdout(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(stdout(), DisableMouseCapture)?; + } + crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/src/types/block.rs b/src/types/block.rs new file mode 100644 index 0000000..cf47fff --- /dev/null +++ b/src/types/block.rs @@ -0,0 +1,24 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogInfo { + pub logs: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HeaderInfo { + pub parent_hash: String, + pub number: String, + pub state_root: String, + pub extrinsics_root: String, + pub digest: LogInfo +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockInfo { + pub header: HeaderInfo, + pub extrinsics: Vec, +} diff --git a/src/types/era.rs b/src/types/era.rs new file mode 100644 index 0000000..882533d --- /dev/null +++ b/src/types/era.rs @@ -0,0 +1,8 @@ +use codec::Decode; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, Decode)] +pub struct EraInfo { + pub index: u32, + pub start: Option, +} diff --git a/src/types/macros.rs b/src/types/macros.rs new file mode 100644 index 0000000..b949851 --- /dev/null +++ b/src/types/macros.rs @@ -0,0 +1,14 @@ +#[macro_export] +macro_rules! rpc_params { + ($($param:expr),*) => {{ + use crate::types::params::RpcParams; + + let mut params = RpcParams::new(); + $( + if let Err(err) = params.insert($param) { + panic!("parameter `{}` cannot be serialized: {:?}", stringify!($param), err); + } + )* + params + }} +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..bdefc8a --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,5 @@ +pub mod block; +pub mod params; +pub mod storage; +pub mod macros; +pub mod era; diff --git a/src/types/params.rs b/src/types/params.rs new file mode 100644 index 0000000..ad4a178 --- /dev/null +++ b/src/types/params.rs @@ -0,0 +1,75 @@ +use color_eyre::Result; +use serde::Serialize; + +#[derive(Debug)] +pub struct RpcParams(ParamsBuilder); + +impl RpcParams { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, value: P) -> Result<()> { + self.0.insert(value) + } + + pub fn build(self) -> String { + self.0.build() + } +} + +impl Default for RpcParams { + fn default() -> Self { + Self(ParamsBuilder::positional()) + } +} + +const PARAM_BYTES_CAPACITY: usize = 128; + +#[derive(Debug)] +pub struct ParamsBuilder { + bytes: Vec, + start: char, + end: char, +} + +impl ParamsBuilder { + fn new(start: char, end: char) -> Self { + Self { bytes: Vec::new(), start, end } + } + + fn positional() -> Self { + Self::new('[', ']') + } + + fn maybe_initialize(&mut self) { + if self.bytes.is_empty() { + self.bytes.reserve(PARAM_BYTES_CAPACITY); + self.bytes.push(self.start as u8); + } + } + + pub fn build(mut self) -> String { + if self.bytes.is_empty() { + return format!("{}{}", self.start, self.end); + } + + let index = self.bytes.len() - 1; + if self.bytes[index] == b',' { + self.bytes[index] = self.end as u8; + } else { + self.bytes.push(self.end as u8); + } + + unsafe { String::from_utf8_unchecked(self.bytes) } + } + + pub fn insert(&mut self, value: P) -> Result<()> { + self.maybe_initialize(); + + serde_json::to_writer(&mut self.bytes, &value)?; + self.bytes.push(b','); + + Ok(()) + } +} diff --git a/src/types/storage.rs b/src/types/storage.rs new file mode 100644 index 0000000..5db24a4 --- /dev/null +++ b/src/types/storage.rs @@ -0,0 +1,32 @@ +#[derive(Debug, Default)] +pub struct GhostStorage<'a> { + module: &'a str, + method: &'a str, +} + +impl<'a> GhostStorage<'a> { + pub fn new() -> Self { + Self { + module: "", + method: "", + } + } + + pub fn with_module(mut self, module: &'a str) -> Self { + self.module = module; + self + } + + pub fn with_method(mut self, method: &'a str) -> Self { + self.method = method; + self + } + + pub fn build_storage_key(&self) -> String { + let module_hex = hex::encode(sp_core::twox_128(self.module.as_bytes())); + let method_hex = hex::encode(sp_core::twox_128(self.method.as_bytes())); + + let storage_key = format!("0x{}{}", module_hex, method_hex); + storage_key + } +} diff --git a/src/widgets/big_text.rs b/src/widgets/big_text.rs new file mode 100644 index 0000000..1eec22c --- /dev/null +++ b/src/widgets/big_text.rs @@ -0,0 +1,305 @@ +#![allow(dead_code)] + +use std::cmp::min; + +use derive_builder::Builder; +use font8x8::UnicodeFonts; +use ratatui::{prelude::*, text::StyledGrapheme, widgets::Widget}; + +#[derive(Debug, Builder, Clone, PartialEq, Eq, Hash)] +#[builder(build_fn(skip))] +pub struct BigText<'a> { + #[builder(default, setter(into))] + pub lines: Vec>, + + #[builder(default, setter(into))] + pub style: Style, + + #[builder(default)] + pub pixel_size: PixelSize, + + #[builder(default)] + pub alignment: Alignment, +} + +impl BigText<'static> { + pub fn builder() -> BigTextBuilder<'static> { + BigTextBuilder::default() + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)] +pub enum PixelSize { + #[default] + Full, + HalfHeight, + HalfWidth, + Quadrant, + ThirdHeight, + Sextant, +} + +impl PixelSize { + pub(crate) fn pixels_per_cell(self) -> (u16, u16) { + match self { + PixelSize::Full => (1, 1), + PixelSize::HalfHeight => (1, 2), + PixelSize::HalfWidth => (2, 1), + PixelSize::Quadrant => (2, 2), + PixelSize::ThirdHeight => (1, 3), + PixelSize::Sextant => (2, 3), + } + } + + pub(crate) fn symbol_for_position(self, glyph: &[u8; 8], row: usize, col: i32) -> char { + match self { + PixelSize::Full => match glyph[row] & (1 << col) { + 0 => ' ', + _ => '█', + }, + PixelSize::HalfHeight => { + let top = glyph[row] & (1 << col); + let bottom = glyph[row + 1] & (1 << col); + get_symbol_half_height(top, bottom) + } + PixelSize::HalfWidth => { + let left = glyph[row] & (1 << col); + let right = glyph[row] & (1 << (col + 1)); + get_symbol_half_width(left, right) + } + PixelSize::Quadrant => { + let top_left = glyph[row] & (1 << col); + let top_right = glyph[row] & (1 << (col + 1)); + let bottom_left = glyph[row + 1] & (1 << col); + let bottom_right = glyph[row + 1] & (1 << (col + 1)); + get_symbol_quadrant_size(top_left, top_right, bottom_left, bottom_right) + } + PixelSize::ThirdHeight => { + let top = glyph[row] & (1 << col); + let is_middle_available = (row + 1) < glyph.len(); + let middle = if is_middle_available { + glyph[row + 1] & (1 << col) + } else { + 0 + }; + let is_bottom_available = (row + 2) < glyph.len(); + let bottom = if is_bottom_available { + glyph[row + 2] & (1 << col) + } else { + 0 + }; + get_symbol_third_height(top, middle, bottom) + } + PixelSize::Sextant => { + let top_left = glyph[row] & (1 << col); + let top_right = glyph[row] & (1 << (col + 1)); + let is_middle_available = (row + 1) < glyph.len(); + let (middle_left, middle_right) = if is_middle_available { + ( + glyph[row + 1] & (1 << col), + glyph[row + 1] & (1 << (col + 1)), + ) + } else { + (0, 0) + }; + let is_bottom_available = (row + 2) < glyph.len(); + let (bottom_left, bottom_right) = if is_bottom_available { + ( + glyph[row + 2] & (1 << col), + glyph[row + 2] & (1 << (col + 1)), + ) + } else { + (0, 0) + }; + get_symbol_sextant_size( + top_left, + top_right, + middle_left, + middle_right, + bottom_left, + bottom_right, + ) + } + } + } +} + +fn get_symbol_half_height(top: u8, bottom: u8) -> char { + match top { + 0 => match bottom { + 0 => ' ', + _ => '▄', + }, + _ => match bottom { + 0 => '▀', + _ => '█', + }, + } +} + +fn get_symbol_half_width(left: u8, right: u8) -> char { + match left { + 0 => match right { + 0 => ' ', + _ => '▐', + }, + _ => match right { + 0 => '▌', + _ => '█', + }, + } +} + +fn get_symbol_quadrant_size( + top_left: u8, + top_right: u8, + bottom_left: u8, + bottom_right: u8, +) -> char { + let top_left = if top_left > 0 { 1 } else { 0 }; + let top_right = if top_right > 0 { 1 } else { 0 }; + let bottom_left = if bottom_left > 0 { 1 } else { 0 }; + let bottom_right = if bottom_right > 0 { 1 } else { 0 }; + + const QUADRANT_SYMBOLS: [char; 16] = [ + ' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█', + ]; + let character_index = top_left + (top_right << 1) + (bottom_left << 2) + (bottom_right << 3); + + QUADRANT_SYMBOLS[character_index] +} + +fn get_symbol_third_height(top: u8, middle: u8, bottom: u8) -> char { + get_symbol_sextant_size(top, top, middle, middle, bottom, bottom) +} + +fn get_symbol_sextant_size( + top_left: u8, + top_right: u8, + middle_left: u8, + middle_right: u8, + bottom_left: u8, + bottom_right: u8, +) -> char { + let top_left = if top_left > 0 { 1 } else { 0 }; + let top_right = if top_right > 0 { 1 } else { 0 }; + let middle_left = if middle_left > 0 { 1 } else { 0 }; + let middle_right = if middle_right > 0 { 1 } else { 0 }; + let bottom_left = if bottom_left > 0 { 1 } else { 0 }; + let bottom_right = if bottom_right > 0 { 1 } else { 0 }; + + const SEXANT_SYMBOLS: [char; 64] = [ + ' ', '🬀', '🬁', '🬂', '🬃', '🬄', '🬅', '🬆', '🬇', '🬈', '🬉', '🬊', '🬋', '🬌', '🬍', '🬎', '🬏', '🬐', + '🬑', '🬒', '🬓', '▌', '🬔', '🬕', '🬖', '🬗', '🬘', '🬙', '🬚', '🬛', '🬜', '🬝', '🬞', '🬟', '🬠', '🬡', + '🬢', '🬣', '🬤', '🬥', '🬦', '🬧', '▐', '🬨', '🬩', '🬪', '🬫', '🬬', '🬭', '🬮', '🬯', '🬰', '🬱', '🬲', + '🬳', '🬴', '🬵', '🬶', '🬷', '🬸', '🬹', '🬺', '🬻', '█', + ]; + let character_index = top_left + + (top_right << 1) + + (middle_left << 2) + + (middle_right << 3) + + (bottom_left << 4) + + (bottom_right << 5); + + SEXANT_SYMBOLS[character_index] +} + +impl BigTextBuilder<'_> { + pub fn left_aligned(&mut self) -> &mut Self { + self.alignment(Alignment::Left) + } + pub fn centered(&mut self) -> &mut Self { + self.alignment(Alignment::Center) + } + pub fn right_aligned(&mut self) -> &mut Self { + self.alignment(Alignment::Right) + } +} + +impl<'a> BigTextBuilder<'a> { + pub fn build(&self) -> BigText<'a> { + BigText { + lines: match &self.lines { + Some(lines) => lines.clone(), + None => Vec::new(), + }, + style: match &self.style { + Some(style) => *style, + None => Style::default(), + }, + pixel_size: self.pixel_size.unwrap_or_default(), + alignment: self.alignment.unwrap_or_default(), + } + } +} + +impl Widget for BigText<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let layout = layout(area, &self.pixel_size, self.alignment, &self.lines); + for (line, line_layout) in self.lines.iter().zip(layout) { + for (g, cell) in line.styled_graphemes(self.style).zip(line_layout) { + render_symbol(g, cell, buf, &self.pixel_size); + } + } + } +} + +fn layout<'a>( + area: Rect, + pixel_size: &PixelSize, + alignment: Alignment, + lines: &'a [Line<'a>], +) -> impl IntoIterator> + 'a { + let (step_x, step_y) = pixel_size.pixels_per_cell(); + let width = 8u16.div_ceil(step_x); + let height = 8u16.div_ceil(step_y); + + (area.top()..area.bottom()) + .step_by(height as usize) + .zip(lines.iter()) + .map(move |(y, line)| { + let offset = get_alignment_offset(area.width, width, alignment, line); + (area.left() + offset..area.right()) + .step_by(width as usize) + .map(move |x| { + let width = min(area.right() - x, width); + let height = min(area.bottom() - y, height); + Rect::new(x, y, width, height) + }) + }) +} + +fn get_alignment_offset<'a>( + area_width: u16, + letter_width: u16, + alignment: Alignment, + line: &'a Line<'a>, +) -> u16 { + let big_line_width = line.width() as u16 * letter_width; + match alignment { + Alignment::Left => 0, + Alignment::Center => (area_width / 2).saturating_sub(big_line_width / 2), + Alignment::Right => area_width.saturating_sub(big_line_width), + } +} + +fn render_symbol(grapheme: StyledGrapheme, area: Rect, buf: &mut Buffer, pixel_size: &PixelSize) { + buf.set_style(area, grapheme.style); + let c = grapheme.symbol.chars().next().unwrap(); + if let Some(glyph) = font8x8::BASIC_FONTS.get(c) { + render_glyph(glyph, area, buf, pixel_size); + } +} + +fn render_glyph(glyph: [u8; 8], area: Rect, buf: &mut Buffer, pixel_size: &PixelSize) { + let (step_x, step_y) = pixel_size.pixels_per_cell(); + + let glyph_vertical_index = (0..glyph.len()).step_by(step_y as usize); + let glyph_horizontal_bit_index = (0..8).step_by(step_x as usize); + + for (y, row) in glyph_vertical_index.zip(area.rows()) { + for (x, col) in glyph_horizontal_bit_index.clone().zip(row.columns()) { + buf[col].set_char(pixel_size.symbol_for_position(&glyph, y, x)); + } + } +} diff --git a/src/widgets/dot_spinner.rs b/src/widgets/dot_spinner.rs new file mode 100644 index 0000000..a31cfbf --- /dev/null +++ b/src/widgets/dot_spinner.rs @@ -0,0 +1,21 @@ +const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub struct DotSpinner { + elements: Vec, +} + +impl Default for DotSpinner { + fn default() -> Self { + Self { + elements: SPINNER.iter().map(|s| s.to_string()).collect::>(), + } + } +} + +impl ToString for DotSpinner { + fn to_string(&self) -> String { + self.elements[((chrono::Utc::now().timestamp_millis() % super::CYCLE) + / (super::CYCLE / self.elements.len() as i64)) as usize] + .to_owned() + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..1f99d9a --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,12 @@ +mod dot_spinner; +mod ogham; +mod vertical_block; +mod big_text; + +pub use dot_spinner::DotSpinner; +pub use vertical_block::VerticalBlocks; +pub use ogham::OghamCenter; +pub use big_text::BigText; +pub use big_text::PixelSize; + +const CYCLE: i64 = 1500; diff --git a/src/widgets/ogham.rs b/src/widgets/ogham.rs new file mode 100644 index 0000000..4ce1767 --- /dev/null +++ b/src/widgets/ogham.rs @@ -0,0 +1,21 @@ +const PROGRESS_CENTER: [&str; 6] = [" ", "ᚐ", "ᚑ", "ᚒ", "ᚓ", "ᚔ"]; + +pub struct OghamCenter { + elements: Vec, +} + +impl Default for OghamCenter { + fn default() -> Self { + Self { + elements: PROGRESS_CENTER.iter().map(|s| s.to_string()).collect::>(), + } + } +} + +impl ToString for OghamCenter { + fn to_string(&self) -> String { + self.elements[((chrono::Utc::now().timestamp_millis() % super::CYCLE) + / (super::CYCLE / self.elements.len() as i64)) as usize] + .to_owned() + } +} diff --git a/src/widgets/vertical_block.rs b/src/widgets/vertical_block.rs new file mode 100644 index 0000000..61034ad --- /dev/null +++ b/src/widgets/vertical_block.rs @@ -0,0 +1,21 @@ +const PROGRESS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + +pub struct VerticalBlocks { + elements: Vec, +} + +impl Default for VerticalBlocks { + fn default() -> Self { + Self { + elements: PROGRESS.iter().map(|s| s.to_string()).collect::>(), + } + } +} + +impl ToString for VerticalBlocks { + fn to_string(&self) -> String { + self.elements[((chrono::Utc::now().timestamp_millis() % super::CYCLE) + / (super::CYCLE / self.elements.len() as i64)) as usize] + .to_owned() + } +} diff --git a/test.json b/test.json new file mode 100644 index 0000000..8e578c6 --- /dev/null +++ b/test.json @@ -0,0 +1,21 @@ +"result":{ + "block":{ + "header":{ + "parentHash":"0x2f27b44ed3c9acef8876d12751355547046477b965c319235baffa67dffa2cc1", + "number":"0x1c0bb", + "stateRoot":"0x1652a3aa3be9a9f600bf8017a49210c5fce85d8eca063fa8ebbf8b3a844e72a1", + "extrinsicsRoot":"0xbdc6d615ae89a071d5385e0015cd3597c5b21a9b294e2346be9b4fd793a21303", + "digest":{ + "logs":[ + "0x0642414245b5010302000000f4b9311100000000eeb68f56e5a888622cd2e4bb10c9c5c2c20d973bc372ed4f10f3b872507059410dba82bdb1dfc5ab439c2a708e1d8726fe151aea2a3cf2e1aa1f5a947e11ce0213f5f9eb5dd3dc3ff34af3e961d5c6d101e1353af79b9c6284e3a2ec26795207", + "0x054241424501015ae1a245af3f3b2caf37930037fab5a8c38c0ca803760dfb571f9df726659e72cf873f5994b7ac97522bb7e635193ae0d4c5a229eb9e3b9aa9ab8472cc5ee587" + ] + } + }, + "extrinsics":[ + "0x280403000bc14676fd9201" + ] + }, + "justifications":null +}, +