diff --git a/Cargo.toml b/Cargo.toml index bac6c1c..77210a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "ghost-eye" authors = ["str3tch "] description = "Application for interacting with Casper/Ghost nodes that are exposing RPC only to the localhost" -version = "0.3.58" +version = "0.3.59" edition = "2021" homepage = "https://git.ghostchain.io/ghostchain" repository = "https://git.ghostchain.io/ghostchain/ghost-eye" diff --git a/config/config.json5 b/config/config.json5 index 3464148..9d02ee5 100644 --- a/config/config.json5 +++ b/config/config.json5 @@ -9,6 +9,15 @@ "hover_title_style": "", "highlight_style": "yellow italic", }, + "Help": { + "normal_style": "", + "hover_style": "bold yellow italic on blue", + "normal_border_style": "blue", + "hover_border_style": "blue", + "normal_title_style": "blue", + "hover_title_style": "", + "highlight_style": "yellow italic", + }, "Explorer": { "normal_style": "", "hover_style": "bold yellow italic on blue", diff --git a/src/app.rs b/src/app.rs index 0b34018..b1afff7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,6 @@ 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; @@ -12,26 +11,12 @@ use crate::{ tui::{Event, Tui}, components::{ menu::Menu, version::Version, explorer::Explorer, wallet::Wallet, - validator::Validator, empty::Empty, - health::Health, fps::FpsCounter, - Component, + validator::Validator, empty::Empty, help::Help, health::Health, + fps::FpsCounter, Component, }, }; -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Mode { - Menu, - Explorer, - Wallet, - Validator, - Empty, -} - -impl Default for Mode { - fn default() -> Self { - Self::Explorer - } -} +pub use crate::modes::Mode; pub struct App { network_tx: Sender, @@ -69,6 +54,7 @@ impl App { Box::new(FpsCounter::default()), Box::new(Health::default()), Box::new(Version::default()), + Box::new(Help::default()), Box::new(Explorer::default()), Box::new(Wallet::default()), Box::new(Validator::default()), @@ -230,25 +216,8 @@ impl App { 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))); - } - } - }, - Mode::Wallet => { if let Some(component) = self.components.get_mut(5) { if let Err(err) = component.draw(frame, frame.area()) { let _ = self @@ -257,7 +226,7 @@ impl App { } } }, - Mode::Validator => { + Mode::Wallet => { if let Some(component) = self.components.get_mut(6) { if let Err(err) = component.draw(frame, frame.area()) { let _ = self @@ -266,6 +235,15 @@ impl App { } } }, + Mode::Validator => { + if let Some(component) = self.components.get_mut(7) { + 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()) { @@ -276,6 +254,14 @@ impl App { } }, } + + for component in self.components.iter_mut().take(5) { + if let Err(err) = (*component).draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("failed to draw: {:?}", err))); + } + } })?; Ok(()) diff --git a/src/components/help.rs b/src/components/help.rs new file mode 100644 index 0000000..a621d5a --- /dev/null +++ b/src/components/help.rs @@ -0,0 +1,191 @@ +use color_eyre::Result; +use crossterm::event::{KeyEvent, KeyCode}; +use ratatui::{ + layout::{Alignment, Constraint, Flex, Layout, Margin, Rect}, + style::{palette::tailwind, Style, Modifier}, + text::Text, + widgets::{ + Block, BorderType, Cell, Clear, HighlightSpacing, Paragraph, Row, + Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, + }, + Frame +}; + +use super::palette::StylePalette; +use super::Component; +use crate::{action::Action, app::Mode, config::Config}; + +#[derive(Debug, Clone)] +pub struct Help { + is_active: bool, + current_mode: Mode, + palette: StylePalette, + scroll_state: ScrollbarState, + table_state: TableState, +} + +const ITEM_HEIGHT: usize = 3; + +impl Help { + fn move_down(&mut self) -> Result> { + let i = match self.table_state.selected() { + Some(i) => { + if i >= self.current_mode.get_help_data().len() { + 0 + } else { + i + 1 + } + }, + None => 0, + }; + self.table_state.select(Some(i)); + self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); + Ok(None) + } + + fn move_up(&mut self) -> Result> { + let i = match self.table_state.selected() { + Some(i) => i.saturating_sub(1), + None => 0, + }; + self.table_state.select(Some(i)); + self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); + Ok(None) + } +} + +impl Default for Help { + fn default() -> Self { + Self { + is_active: false, + current_mode: Mode::Menu, + palette: Default::default(), + scroll_state: ScrollbarState::new((Mode::Menu.get_help_data().len() - 1) * ITEM_HEIGHT), + table_state: TableState::default(), + } + } +} + +impl Component for Help { + fn register_config_handler(&mut self, config: Config) -> Result<()> { + if let Some(style) = config.styles.get(&crate::app::Mode::Help) { + self.palette.with_normal_style(style.get("normal_style").copied()); + self.palette.with_normal_border_style(style.get("normal_border_style").copied()); + self.palette.with_normal_title_style(style.get("normal_title_style").copied()); + self.palette.with_popup_style(style.get("popup_style").copied()); + self.palette.with_popup_title_style(style.get("popup_title_style").copied()); + } + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::Help if !self.is_active => self.is_active = true, + Action::SetActiveScreen(mode) => { + if self.current_mode != mode { + self.current_mode = mode; + self.table_state.select(None); + self.scroll_state = ScrollbarState::new((self.current_mode.get_help_data().len() - 1) * ITEM_HEIGHT); + } + } + _ => {} + }; + Ok(None) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + match key.code { + KeyCode::Up | KeyCode::Char('k') => self.move_up(), + KeyCode::Down | KeyCode::Char('j') => self.move_down(), + KeyCode::Esc if self.is_active => { + self.is_active = false; + Ok(Some(Action::SetActiveScreen(self.current_mode))) + }, + _ => Ok(None), + } + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + if self.is_active { + let highlight_symbol = Text::from(vec![ + "".into(), + " █ ".into(), + "".into(), + ]); + + let table = Table::new( + self.current_mode + .get_help_data() + .into_iter() + .enumerate() + .map(|(i, content)| { + let color = match i % 2 { + 0 => tailwind::SLATE.c950, + _ => tailwind::SLATE.c900, + }; + + Row::from(content + .into_iter() + .map(|data| Cell::from(Text::from(*data))) + .collect::() + .style(Style::default().fg(tailwind::BLUE.c200).bg(color)) + .height(ITEM_HEIGHT as u16)) + }) + .collect::>(), + self.current_mode.get_help_constraints() + ) + .header(self.current_mode + .get_help_headers() + .into_iter() + .map(|h| Cell::from(Text::from(*h))) + .collect::() + .style(Style::default() + .fg(tailwind::SLATE.c200) + .bg(tailwind::BLUE.c900) + ) + .height(1) + ) + .highlight_style(Style::default() + .add_modifier(Modifier::REVERSED) + .fg(tailwind::BLUE.c400) + .bg(tailwind::YELLOW.c200)) + .highlight_spacing(HighlightSpacing::Always) + .highlight_symbol(highlight_symbol) + .block(Block::default() + .style(Style::default().bg(tailwind::SLATE.c950)) + .title_alignment(Alignment::Right) + .title_style(tailwind::YELLOW.c200) + .title(self.current_mode.get_help_title())); + + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None); + + let footer = Paragraph::new(Text::from(self.current_mode.get_help_text())) + .style(Style::default().fg(tailwind::SLATE.c200).bg(tailwind::SLATE.c950)) + .centered() + .block(Block::bordered() + .border_type(BorderType::Double) + .border_style(Style::default().fg(tailwind::BLUE.c400)) + ); + + let v = Layout::vertical([Constraint::Length(23)]).flex(Flex::Center); + let h = Layout::horizontal([Constraint::Length(65)]).flex(Flex::Center); + + let [area] = v.areas(area); + let [area] = h.areas(area); + + let [main_area, footer_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + ]).areas(area); + + frame.render_widget(Clear, area); + frame.render_stateful_widget(table, main_area, &mut self.table_state); + frame.render_stateful_widget(scrollbar, main_area.inner(Margin::new(1, 2)), &mut self.scroll_state); + frame.render_widget(footer, footer_area); + } + Ok(()) + } +} diff --git a/src/components/menu.rs b/src/components/menu.rs index feea7de..fdb345b 100644 --- a/src/components/menu.rs +++ b/src/components/menu.rs @@ -121,6 +121,10 @@ impl Component for Menu { _ => Ok(Some(Action::SetActiveScreen(Mode::Empty))), } }, + KeyCode::Char('?') if self.is_active => { + self.is_active = false; + Ok(Some(Action::Help)) + }, _ => Ok(None), } } diff --git a/src/components/mod.rs b/src/components/mod.rs index f609fde..583e81d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -17,6 +17,7 @@ pub mod explorer; pub mod wallet; pub mod validator; pub mod empty; +pub mod help; pub trait Component { fn register_network_handler(&mut self, tx: Sender) -> Result<()> { diff --git a/src/main.rs b/src/main.rs index 589fd3e..1a200f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use subxt::{ backend::rpc::RpcClient, }; +mod modes; mod action; mod app; mod cli; diff --git a/src/modes.rs b/src/modes.rs new file mode 100644 index 0000000..49335e7 --- /dev/null +++ b/src/modes.rs @@ -0,0 +1,69 @@ +use ratatui::layout::Constraint; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + Menu, + Help, + Explorer, + Wallet, + Validator, + Empty, +} + +impl Default for Mode { + fn default() -> Self { + Self::Explorer + } +} + +impl Mode { + const MENU_DATA_TITLE: &str = "Help for navigation menu "; + const MENU_DATA_HEADERS: &[&str] = &["Hot Key", "Target", "Description"]; + const MENU_DATA_CONSTRAINTS: &[Constraint] = &[Constraint::Length(10), Constraint::Min(0)]; + const MENU_DATA_ROWS: &[&[&str]] = &[ + &["(↑) | k", "Select side menu item above"], + &["(↓) | j", "Select side menu item below"], + &["(→) | l", "Select next tab in the screen"], + &["(←) | h", "Select previous tab in the screen"], + &["Enter", "Enter the selected screen"], + &["Esc", "Navigate back to the side menu"], + ]; + + const NON_EXISTENT_TITLE: &str = "Non existent"; + const NON_EXISTENT_DATA_HEADERS: &[&str] = &["Empty header"]; + const NON_EXISTENT_DATA_CONSTRAINTS: &[Constraint] = &[Constraint::Percentage(100)]; + const NON_EXISTENT_DATA_ROWS: &[&[&str]] = &[&["Not implemented yet"]]; + + pub fn get_help_title(&self) -> &'static str { + match self { + Self::Menu => &Self::MENU_DATA_TITLE, + _ => &Self::NON_EXISTENT_TITLE, + } + } + + pub fn get_help_headers(&self) -> &'static [&'static str] { + match self { + Self::Menu => &Self::MENU_DATA_HEADERS, + _ => &Self::NON_EXISTENT_DATA_HEADERS, + } + } + + pub fn get_help_data(&self) -> &'static [&'static [&'static str]] { + match self { + Self::Menu => &Self::MENU_DATA_ROWS, + _ => &Self::NON_EXISTENT_DATA_ROWS, + } + } + + pub fn get_help_constraints(&self) -> &'static [Constraint] { + match self { + Self::Menu => &Self::MENU_DATA_CONSTRAINTS, + _ => &Self::NON_EXISTENT_DATA_CONSTRAINTS, + } + } + + pub fn get_help_text(&self) -> &'static str { + "(Esc) close | (↑) move up | (↓) move down" + } +}