component for help popup

Signed-off-by: Uncle Stretch <uncle.stretch@ghostchain.io>
This commit is contained in:
Uncle Stretch 2025-06-10 18:46:28 +03:00
parent 7561c525e3
commit 8eb3871e34
Signed by: str3tch
GPG Key ID: 84F3190747EE79AA
8 changed files with 298 additions and 37 deletions

View File

@ -2,7 +2,7 @@
name = "ghost-eye"
authors = ["str3tch <stretch@ghostchain.io>"]
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"

View File

@ -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",

View File

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

191
src/components/help.rs Normal file
View File

@ -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<Option<Action>> {
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<Option<Action>> {
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<Option<Action>> {
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<Option<Action>> {
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::<Row>()
.style(Style::default().fg(tailwind::BLUE.c200).bg(color))
.height(ITEM_HEIGHT as u16))
})
.collect::<Vec<_>>(),
self.current_mode.get_help_constraints()
)
.header(self.current_mode
.get_help_headers()
.into_iter()
.map(|h| Cell::from(Text::from(*h)))
.collect::<Row>()
.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(())
}
}

View File

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

View File

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

View File

@ -5,6 +5,7 @@ use subxt::{
backend::rpc::RpcClient,
};
mod modes;
mod action;
mod app;
mod cli;

69
src/modes.rs Normal file
View File

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