initial ghost-eye sketches with basic functionality

Signed-off-by: Uncle Stretch <uncle.stretch@ghostchain.io>
This commit is contained in:
Uncle Stretch 2024-11-14 15:46:38 +03:00
commit b1d7add8a3
Signed by: str3tch
GPG Key ID: 84F3190747EE79AA
52 changed files with 4276 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -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/

46
Cargo.toml Normal file
View File

@ -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"] }

13
build.rs Normal file
View File

@ -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()
}

16
config/config.json5 Normal file
View File

@ -0,0 +1,16 @@
{
"keybindings": {
"Explorer": {
"<q>": "Quit",
"<Ctrl-d>": "Quit",
"<Ctrl-c>": "Quit",
"<Ctrl-z>": "Suspend",
},
"Empty": {
"<q>": "Quit",
"<Ctrl-d>": "Quit",
"<Ctrl-c>": "Quit",
"<Ctrl-z>": "Suspend",
}
}
}

49
src/action.rs Normal file
View File

@ -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<String>),
SetSyncState(Option<u32>, bool, bool),
SetGenesisHash(Option<String>),
SetChainName(Option<String>),
SetNodeVersion(Option<String>),
SetLatestBlock(String, BlockInfo),
SetFinalizedBlock(String, BlockInfo),
SetActiveEra(EraInfo),
SetEpoch(u64, u64),
SetValidators(Vec<String>),
SetPendingExtrinsicsLength(usize),
}

259
src/app.rs Normal file
View File

@ -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>,
action_tx: UnboundedSender<Action>,
action_rx: UnboundedReceiver<Action>,
frame_rate: f32,
tick_rate: f32,
mouse: bool,
paste: bool,
config: Config,
components: Vec<Box<dyn Component>>,
should_quite: bool,
should_suspend: bool,
mode: Mode,
last_tick_key_events: Vec<KeyEvent>,
}
impl App {
pub fn new(
network_tx: Sender<Action>,
action_tx: UnboundedSender<Action>,
action_rx: UnboundedReceiver<Action>,
) -> Result<Self> {
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(())
}
}

57
src/cli.rs Normal file
View File

@ -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}"
)
}

125
src/components/empty.rs Normal file
View File

@ -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<Line> {
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<Line> {
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<Option<Action>> {
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(())
}
}

View File

@ -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<Option<Action>> {
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(())
}
}

View File

@ -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<Option<Action>> {
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(())
}
}

View File

@ -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<Option<Action>> {
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(())
}
}

View File

@ -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<BlockInfo>,
extrinsics: HashMap<String, Vec<String>>,
logs: HashMap<String, Vec<String>>,
validators: Vec<String>,
palette: StylePalette,
max_block_len: u32,
is_active: bool,
used_paragraph_index: usize,
used_block_index: Option<usize>,
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<String>) -> Result<()> {
self.validators = validators;
Ok(())
}
fn get_author_from_digest(&self, logs: Vec<String>) -> Option<String> {
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::<Vec<_>>();
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<String>,
extrinsics: Vec<String>,
) -> 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<String>,
extrinsics: Vec<String>,
) -> 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(&current_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!("{}...{}",
&current_block.hash[..len_for_hash as usize],
&current_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<Line> {
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(&current_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<Line> {
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<Option<Action>> {
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<Option<Action>> {
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(())
}
}

View File

@ -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<Bar> = 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<Option<Action>> {
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(())
}
}

View File

@ -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<Option<Action>> {
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(())
}
}

View File

@ -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<Option<Action>> {
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(())
}
}

View File

@ -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<Box<dyn Component>>
}
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<Option<Action>> {
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)
}

93
src/components/fps.rs Normal file
View File

@ -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<Option<Action>> {
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(())
}
}

111
src/components/health.rs Normal file
View File

@ -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<String>,
peers: Option<u32>,
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<u32>, 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<String>) -> 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<Option<Action>> {
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(())
}
}

137
src/components/menu.rs Normal file
View File

@ -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<UnboundedSender<Action>>,
items: Vec<String>,
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<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
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(())
}
}

95
src/components/mod.rs Normal file
View File

@ -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<Action>) -> Result<()> {
let _ = tx;
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
let _ = config;
Ok(())
}
fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> {
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<Option<Action>> {
let _ = key;
Ok(None)
}
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
let _ = mouse;
Ok(None)
}
fn init(&mut self, area: Size) -> Result<()> {
let _ = area;
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
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
}

81
src/components/version.rs Normal file
View File

@ -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<String>,
node_version: Option<String>,
chain_name: Option<String>,
palette: StylePalette,
}
impl Version {
fn set_chain_name(&mut self, chain_name: Option<String>) -> Result<()> {
self.chain_name = chain_name;
Ok(())
}
fn set_node_version(&mut self, node_version: Option<String>) -> Result<()> {
self.node_version = node_version;
Ok(())
}
fn set_genesis_hash(&mut self, genesis_hash: Option<String>) -> 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<Option<Action>> {
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(())
}
}

454
src/config.rs Normal file
View File

@ -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<PathBuf> =
env::var(format!("{}_DATA", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
pub static ref CONFIG_FOLDER: Option<PathBuf> =
env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
}
impl Config {
pub fn new() -> Result<Self, config::ConfigError> {
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> {
ProjectDirs::from("com", "ghost", env!("CARGO_PKG_NAME"))
}
#[derive(Clone, Debug, Default, Deref, DerefMut)]
pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
impl<'de> Deserialize<'de> for KeyBindings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de>,
{
let parsed_map = HashMap::<Mode, HashMap<String, Action>>::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<KeyEvent, String> {
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<KeyEvent, String> {
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<Vec<KeyEvent>, 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::<Vec<_>>();
sequence.into_iter().map(parse_key_event).collect()
}
#[derive(Clone, Debug, Default, Deref, DerefMut)]
pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
impl<'de> Deserialize<'de> for Styles {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de>
{
let parsed_map = HashMap::<Mode, HashMap<String, String>>::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<Color> {
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::<u8>()
.unwrap_or_default();
Some(Color::Indexed(c.wrapping_shl(8)))
} else if s.contains("color") {
let c = s
.trim_start_matches("color")
.parse::<u8>()
.unwrap_or_default();
Some(Color::Indexed(c))
} else if s.contains("gray") {
let c = 232
+ s.trim_start_matches("gray")
.parse::<u8>()
.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
}
}

67
src/errors.rs Normal file
View File

@ -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)
};
}

39
src/logging.rs Normal file
View File

@ -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(())
}

53
src/main.rs Normal file
View File

@ -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<action::Action>,
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(())
}

33
src/network/active_era.rs Normal file
View File

@ -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::<String>("state_getStorage", rpc_params![storage_key])
.await
.map(|r| {
hex::decode(r.result.trim_start_matches("0x")).or::<Vec<u8>>(Ok(vec![]))
})
.unwrap()
.unwrap();
let active_era = EraInfo::decode(&mut &result_hex[..])?;
self.0.action_tx.send(Action::SetActiveEra(active_era))?;
Ok(())
}
}

20
src/network/chain_name.rs Normal file
View File

@ -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::<String>("system_chain", RpcParams::new())
.await
.ok()
.map(|response| response.result);
self.0.action_tx.send(Action::SetChainName(chain_name))?;
Ok(())
}
}

View File

@ -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<u64> {
let storage_key = GhostStorage::new()
.with_module(module)
.with_method(method)
.build_storage_key();
let result_hex = self
.0
.send::<String>("state_getStorage", rpc_params![storage_key])
.await
.map(|r| {
hex::decode(r.result.trim_start_matches("0x")).or::<Vec<u8>>(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(())
}
}

View File

@ -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<String>
}
#[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::<String>("chain_getFinalizedHead", RpcParams::new())
.await
.map_or(String::new(), |response| response.result);
let finalized_block = self
.0
.send::<LatestBlockResponse>("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(())
}
}

View File

@ -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::<String>("chain_getBlockHash", rpc_params!["0"])
.await
.ok()
.map(|response| response.result);
self.0.action_tx.send(Action::SetGenesisHash(genesis_hash))?;
Ok(())
}
}

View File

@ -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::<HealthCheckResponse>("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(())
}
}

View File

@ -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<String>
}
#[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::<String>("chain_getBlockHash", RpcParams::new())
.await
.map_or(String::new(), |response| response.result);
let latest_block = self
.0
.send::<LatestBlockResponse>("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(())
}
}

165
src/network/mod.rs Normal file
View File

@ -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<Arc<Client>> = Lazy::new(|| Arc::new(Client::new()));
const DEFAULT_URL: &str = "http://localhost:9945";
pub type AppActionSender = UnboundedSender<Action>;
#[derive(Debug, Deserialize)]
pub struct GhostResponse<ResponseType> {
result: ResponseType,
}
#[derive(Default)]
struct GhostRequestBuilder<'a> {
action_tx: Option<AppActionSender>,
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<ResponseType>(
&self,
method: &str,
params: RpcParams,
) -> Result<GhostResponse<ResponseType>>
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::<GhostResponse<ResponseType>>()
.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(())
}
}
}

20
src/network/node_name.rs Normal file
View File

@ -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::<String>("system_name", RpcParams::new())
.await
.ok()
.map(|response| response.result);
self.0.action_tx.send(Action::SetNodeName(name))?;
Ok(())
}
}

View File

@ -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::<String>("system_version", RpcParams::new())
.await
.ok()
.map(|response| response.result);
self.0.action_tx.send(Action::SetNodeVersion(version))?;
Ok(())
}
}

View File

21
src/network/tx_pool.rs Normal file
View File

@ -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::<Vec<String>>("author_pendingExtrinsics", RpcParams::new())
.await
.ok()
.map(|response| response.result)
.unwrap_or_default();
self.0.action_tx.send(Action::SetPendingExtrinsicsLength(tx_pool.len()))?;
Ok(())
}
}

37
src/network/validators.rs Normal file
View File

@ -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::<String>("state_getStorage", rpc_params![storage_key])
.await
.map(|response| {
hex::decode(response.result.trim_start_matches("0x"))
.ok()
.unwrap_or_default()
})
.unwrap();
let validators = <Vec<AccountId32>>::decode(&mut &result_hex[..])
.ok()
.unwrap_or_default()
.iter()
.map(|v| v.to_ss58check_with_version(Ss58AddressFormat::custom(1996)))
.collect::<Vec<_>>();
self.0.action_tx.send(Action::SetValidators(validators))?;
Ok(())
}
}

103
src/palette.rs Normal file
View File

@ -0,0 +1,103 @@
use ratatui::style::{Style, Color, Modifier};
use ratatui::widgets::block::BorderType;
#[derive(Debug, Clone)]
pub struct StylePalette {
background: Option<Color>,
foreground: Option<Color>,
modifiers: Vec<Modifier>,
background_hover: Option<Color>,
foreground_hover: Option<Color>,
modifiers_hover: Vec<Modifier>,
border_color: Option<Color>,
title_color: Option<Color>,
border_type: BorderType,
border_color_hover: Option<Color>,
//title_color_hover: Option<Color>,
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
}
}

220
src/tui.rs Normal file
View File

@ -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<Backend<Stdout>>,
pub task: JoinHandle<()>,
pub cancellation_token: CancellationToken,
pub event_rx: UnboundedReceiver<Event>,
pub event_tx: UnboundedSender<Event>,
pub frame_rate: f64,
pub tick_rate: f64,
pub mouse: bool,
pub paste: bool,
}
impl Tui {
pub fn new() -> Result<Self> {
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<Event>,
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<Event> {
self.event_rx.recv().await
}
}
impl Deref for Tui {
type Target = ratatui::Terminal<Backend<Stdout>>;
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();
}
}

24
src/types/block.rs Normal file
View File

@ -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<String>,
}
#[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<String>,
}

8
src/types/era.rs Normal file
View File

@ -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<u64>,
}

14
src/types/macros.rs Normal file
View File

@ -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
}}
}

5
src/types/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod block;
pub mod params;
pub mod storage;
pub mod macros;
pub mod era;

75
src/types/params.rs Normal file
View File

@ -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<P: Serialize>(&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<u8>,
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<P: Serialize>(&mut self, value: P) -> Result<()> {
self.maybe_initialize();
serde_json::to_writer(&mut self.bytes, &value)?;
self.bytes.push(b',');
Ok(())
}
}

32
src/types/storage.rs Normal file
View File

@ -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
}
}

305
src/widgets/big_text.rs Normal file
View File

@ -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<Line<'a>>,
#[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<Item = impl IntoIterator<Item = Rect>> + '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));
}
}
}

View File

@ -0,0 +1,21 @@
const SPINNER: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
pub struct DotSpinner {
elements: Vec<String>,
}
impl Default for DotSpinner {
fn default() -> Self {
Self {
elements: SPINNER.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
}
}
}
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()
}
}

12
src/widgets/mod.rs Normal file
View File

@ -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;

21
src/widgets/ogham.rs Normal file
View File

@ -0,0 +1,21 @@
const PROGRESS_CENTER: [&str; 6] = ["", "", "", "", "", ""];
pub struct OghamCenter {
elements: Vec<String>,
}
impl Default for OghamCenter {
fn default() -> Self {
Self {
elements: PROGRESS_CENTER.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
}
}
}
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()
}
}

View File

@ -0,0 +1,21 @@
const PROGRESS: [&str; 8] = ["", "", "", "", "", "", "", ""];
pub struct VerticalBlocks {
elements: Vec<String>,
}
impl Default for VerticalBlocks {
fn default() -> Self {
Self {
elements: PROGRESS.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
}
}
}
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()
}
}

21
test.json Normal file
View File

@ -0,0 +1,21 @@
"result":{
"block":{
"header":{
"parentHash":"0x2f27b44ed3c9acef8876d12751355547046477b965c319235baffa67dffa2cc1",
"number":"0x1c0bb",
"stateRoot":"0x1652a3aa3be9a9f600bf8017a49210c5fce85d8eca063fa8ebbf8b3a844e72a1",
"extrinsicsRoot":"0xbdc6d615ae89a071d5385e0015cd3597c5b21a9b294e2346be9b4fd793a21303",
"digest":{
"logs":[
"0x0642414245b5010302000000f4b9311100000000eeb68f56e5a888622cd2e4bb10c9c5c2c20d973bc372ed4f10f3b872507059410dba82bdb1dfc5ab439c2a708e1d8726fe151aea2a3cf2e1aa1f5a947e11ce0213f5f9eb5dd3dc3ff34af3e961d5c6d101e1353af79b9c6284e3a2ec26795207",
"0x054241424501015ae1a245af3f3b2caf37930037fab5a8c38c0ca803760dfb571f9df726659e72cf873f5994b7ac97522bb7e635193ae0d4c5a229eb9e3b9aa9ab8472cc5ee587"
]
}
},
"extrinsics":[
"0x280403000bc14676fd9201"
]
},
"justifications":null
},