use core::fmt;
use std::cell::RefCell;
use std::cell::RefMut;
use std::collections::HashMap;
use std::error::Error;
use std::io;
use std::io::Stdout;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use crossterm::event;
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableMouseCapture;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::disable_raw_mode;
use crossterm::terminal::enable_raw_mode;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use neptune_cash::application::config::network::Network;
use neptune_cash::application::rpc::auth;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::List;
use ratatui::widgets::ListItem;
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use ratatui::Terminal;
use strum::EnumCount;
use strum::EnumIter;
use strum::IntoEnumIterator;
use tokio::sync::Mutex;
use tokio::time::sleep;
use super::address_screen::AddressScreen;
use super::history_screen::HistoryScreen;
use super::mempool_screen::MempoolScreen;
use super::overview_screen::OverviewScreen;
use super::peers_screen::PeerSortColumn;
use super::peers_screen::PeersScreen;
use super::peers_screen::SortOrder;
use super::receive_screen::ReceiveScreen;
use super::screen::Screen;
use super::send_screen::SendScreen;
use crate::dashboard_rpc_client::DashboardRpcClient;
use crate::utxos_screen::UtxosScreen;
#[derive(Debug, Parser, Clone)]
#[clap(name = "neptune-dashboard", about = "Terminal user interface")]
pub struct Config {
#[clap(short, long, default_value = "9799", value_name = "port")]
pub port: u16,
#[clap(long)]
pub data_dir: Option<PathBuf>,
#[clap(long, default_value = "standing", value_name = "COLUMN")]
pub peer_sort_column: PeerSortColumn,
#[clap(long, default_value = "descending", value_name = "ORDER")]
pub peer_sort_order: SortOrder,
}
#[derive(Debug, Clone, Copy, EnumIter, PartialEq, Eq, EnumCount, Hash)]
enum MenuItem {
Overview,
Peers,
History,
Receive,
Send,
Address,
Mempool,
Utxos,
Quit,
}
impl MenuItem {
pub fn next(&self) -> Self {
MenuItem::from((usize::from(*self) + 1) % MenuItem::COUNT)
}
pub fn previous(&self) -> Self {
MenuItem::from((usize::from(*self) + MenuItem::COUNT - 1) % MenuItem::COUNT)
}
}
impl From<MenuItem> for usize {
fn from(mi: MenuItem) -> Self {
match mi {
MenuItem::Overview => 0,
MenuItem::Peers => 1,
MenuItem::History => 2,
MenuItem::Receive => 3,
MenuItem::Send => 4,
MenuItem::Address => 5,
MenuItem::Mempool => 6,
MenuItem::Utxos => 7,
MenuItem::Quit => 8,
}
}
}
impl From<usize> for MenuItem {
fn from(u: usize) -> Self {
for mi in MenuItem::iter() {
if mi as usize == u {
return mi;
}
}
panic!("Should not get here.");
}
}
impl fmt::Display for MenuItem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MenuItem::Overview => write!(f, "Overview"),
MenuItem::Peers => write!(f, "Peers"),
MenuItem::History => write!(f, "History"),
MenuItem::Receive => write!(f, "Receive"),
MenuItem::Send => write!(f, "Send"),
MenuItem::Address => write!(f, "Addresses"),
MenuItem::Mempool => write!(f, "Mempool"),
MenuItem::Utxos => write!(f, "UTXOs"),
MenuItem::Quit => write!(f, "Quit"),
}
}
}
#[derive(Debug, Clone)]
pub enum ConsoleIO {
#[allow(dead_code)]
Output(String),
InputRequested(String),
InputSupplied(String),
}
#[derive(Debug, Clone)]
pub enum DashboardEvent {
RefreshScreen,
ConsoleEvent(Event),
ConsoleMode(ConsoleIO),
Shutdown(String),
}
pub struct DashboardApp {
running: bool,
current_menu_item: MenuItem,
menu_in_focus: bool,
overview_screen: Rc<RefCell<OverviewScreen>>,
peers_screen: Rc<RefCell<PeersScreen>>,
address_screen: Rc<RefCell<AddressScreen>>,
history_screen: Rc<RefCell<HistoryScreen>>,
receive_screen: Rc<RefCell<ReceiveScreen>>,
send_screen: Rc<RefCell<SendScreen>>,
mempool_screen: Rc<RefCell<MempoolScreen>>,
utxos_screen: Rc<RefCell<UtxosScreen>>,
screens: HashMap<MenuItem, Rc<RefCell<dyn Screen>>>,
output: String,
console_io: Arc<Mutex<Vec<ConsoleIO>>>,
network: Network,
}
impl DashboardApp {
pub fn new(
config: Arc<Config>,
rpc_server: Arc<DashboardRpcClient>,
network: Network,
token: auth::Token,
) -> Self {
let mut screens = HashMap::<MenuItem, Rc<RefCell<dyn Screen>>>::new();
let overview_screen = Rc::new(RefCell::new(OverviewScreen::new(
rpc_server.clone(),
network,
token,
)));
let overview_screen_dyn = Rc::clone(&overview_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Overview, Rc::clone(&overview_screen_dyn));
let peers_screen = Rc::new(RefCell::new(PeersScreen::new(
config.clone(),
rpc_server.clone(),
token,
)));
let peers_screen_dyn = Rc::clone(&peers_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Peers, Rc::clone(&peers_screen_dyn));
let history_screen = Rc::new(RefCell::new(HistoryScreen::new(rpc_server.clone(), token)));
let history_screen_dyn = Rc::clone(&history_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::History, Rc::clone(&history_screen_dyn));
let receive_screen = Rc::new(RefCell::new(ReceiveScreen::new(
rpc_server.clone(),
network,
token,
)));
let receive_screen_dyn = Rc::clone(&receive_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Receive, Rc::clone(&receive_screen_dyn));
let send_screen = Rc::new(RefCell::new(SendScreen::new(
rpc_server.clone(),
network,
token,
)));
let send_screen_dyn = Rc::clone(&send_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Send, Rc::clone(&send_screen_dyn));
let address_screen = Rc::new(RefCell::new(AddressScreen::new(
rpc_server.clone(),
network,
token,
)));
let address_screen_dyn = Rc::clone(&address_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Address, Rc::clone(&address_screen_dyn));
let mempool_screen = Rc::new(RefCell::new(MempoolScreen::new(rpc_server.clone(), token)));
let mempool_screen_dyn = Rc::clone(&mempool_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Mempool, Rc::clone(&mempool_screen_dyn));
let utxos_screen = Rc::new(RefCell::new(UtxosScreen::new(rpc_server.clone(), token)));
let utxos_screen_dyn = Rc::clone(&utxos_screen) as Rc<RefCell<dyn Screen>>;
screens.insert(MenuItem::Utxos, Rc::clone(&utxos_screen_dyn));
Self {
running: false,
current_menu_item: MenuItem::Overview,
menu_in_focus: true,
overview_screen,
peers_screen,
history_screen,
receive_screen,
send_screen,
address_screen,
mempool_screen,
utxos_screen,
screens,
output: "".to_string(),
console_io: Arc::new(Mutex::new(vec![])),
network,
}
}
pub fn start(&mut self) {
self.running = true;
}
pub fn stop(&mut self) {
self.running = false;
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn enable_raw_mode() -> Result<Terminal<CrosstermBackend<Stdout>>, Box<dyn Error>> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn disable_raw_mode(
mut terminal: Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), Box<dyn Error>> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
pub fn current_screen(&mut self) -> RefMut<'_, dyn Screen> {
self.screens
.get(&self.current_menu_item)
.unwrap()
.as_ref()
.borrow_mut()
}
pub fn have_current_screen(&mut self) -> bool {
self.screens.contains_key(&self.current_menu_item)
}
pub async fn run(
config: Config,
client: DashboardRpcClient,
network: Network,
token: auth::Token,
) -> Result<String, Box<dyn Error>> {
let mut app = DashboardApp::new(Arc::new(config), Arc::new(client), network, token);
let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel::<()>(2);
let mut terminal = Self::enable_raw_mode()?;
app.start();
if app.have_current_screen() {
app.current_screen().activate();
}
terminal.draw(|f| app.render(f))?;
let mut continue_running = true;
while continue_running {
let mut console_input = None;
{
let mut draw = false;
let mut console_queue = app.console_io.lock().await;
if !console_queue.is_empty() {
match console_queue.first().unwrap() {
ConsoleIO::Output(string) => {
Self::disable_raw_mode(terminal)?;
sleep(Duration::from_millis(200)).await;
println!("{string}");
let mut str = "".to_string();
io::stdin().read_line(&mut str)?;
terminal = Self::enable_raw_mode()?;
draw = true;
}
ConsoleIO::InputRequested(string) => {
Self::disable_raw_mode(terminal)?;
sleep(Duration::from_millis(200)).await;
println!("{string}");
let mut str = "".to_string();
io::stdin().read_line(&mut str)?;
console_input = Some(str);
terminal = Self::enable_raw_mode()?;
draw = true;
}
ConsoleIO::InputSupplied(_) => panic!("Should not get here."),
}
console_queue.remove(0);
}
drop(console_queue);
if draw {
terminal.draw(|f| app.render(f))?;
}
}
if let Some(string) = console_input {
app.handle(
&mut terminal,
DashboardEvent::ConsoleMode(ConsoleIO::InputSupplied(string)),
refresh_tx.clone(),
)
.await?;
}
if refresh_rx.try_recv().is_ok() {
terminal.draw(|f| app.render(f))?;
}
if event::poll(Duration::from_millis(200))? {
if let Ok(event) = event::read() {
app.handle(
&mut terminal,
DashboardEvent::ConsoleEvent(event),
refresh_tx.clone(),
)
.await?;
terminal.draw(|f| app.render(f))?;
}
}
if app.have_current_screen() {
let maybe_event_arc = app.current_screen().escalatable_event();
if maybe_event_arc.lock().unwrap().is_some() {
let event = maybe_event_arc.lock().unwrap().clone().unwrap();
app.handle(&mut terminal, event, refresh_tx.clone()).await?;
*maybe_event_arc.lock().unwrap() = None;
};
}
continue_running = app.is_running();
}
app.stop();
Self::disable_raw_mode(terminal)?;
Ok(app.output.to_string())
}
async fn handle(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
event: DashboardEvent,
refresh_tx: tokio::sync::mpsc::Sender<()>,
) -> Result<Option<Event>, Box<dyn Error>> {
if let DashboardEvent::RefreshScreen = event {
terminal.draw(|f| self.render(f))?;
} else if let DashboardEvent::Shutdown(error_message) = event {
self.stop();
self.output = error_message + "\n";
} else if self.menu_in_focus {
if let DashboardEvent::ConsoleEvent(Event::Key(key)) = event {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
if self.current_menu_item == MenuItem::Quit {
self.running = false;
} else {
self.current_menu_item = MenuItem::Quit;
}
}
KeyCode::Enter => {
if self.current_menu_item == MenuItem::Quit {
self.running = false;
} else {
self.menu_in_focus = false;
if self.have_current_screen() {
self.current_screen().focus();
}
}
}
KeyCode::Up => {
if self.have_current_screen() {
self.current_screen().deactivate();
}
self.current_menu_item = self.current_menu_item.previous();
if self.have_current_screen() {
self.current_screen().activate();
}
}
KeyCode::Down => {
if self.have_current_screen() {
self.current_screen().deactivate();
}
self.current_menu_item = self.current_menu_item.next();
if self.have_current_screen() {
self.current_screen().activate();
}
}
_ => {}
}
}
}
}
else {
let escalated: Option<DashboardEvent> = match self.current_menu_item {
MenuItem::Address => {
let mut address_screen = self.address_screen.as_ref().borrow_mut();
address_screen.handle(event)
}
MenuItem::History => {
let mut history_screen = self.history_screen.as_ref().borrow_mut();
history_screen.handle(event)
}
MenuItem::Peers => {
let mut peers_screen = self.peers_screen.as_ref().borrow_mut();
peers_screen.handle(event)
}
MenuItem::Receive => {
let mut receive_screen = self.receive_screen.as_ref().borrow_mut();
receive_screen.handle(event)
}
MenuItem::Send => {
let mut send_screen = self.send_screen.as_ref().borrow_mut();
send_screen.handle(event, refresh_tx)
}
MenuItem::Utxos => {
let mut utxos_screen = self.utxos_screen.as_ref().borrow_mut();
utxos_screen.handle(event)
}
_ => Some(event),
};
match escalated {
Some(DashboardEvent::RefreshScreen) => {
terminal.draw(|f| self.render(f))?;
}
Some(DashboardEvent::ConsoleEvent(Event::Key(key)))
if key.kind == KeyEventKind::Press =>
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
if self.have_current_screen() {
self.current_screen().unfocus();
}
self.menu_in_focus = true;
}
_ => {}
}
}
Some(DashboardEvent::ConsoleEvent(_non_key_event)) => {}
Some(DashboardEvent::ConsoleMode(console_io)) => {
let mut console_io_mut = self.console_io.lock().await;
console_io_mut.push(console_io);
}
_ => {
}
}
}
Ok(None)
}
fn render(&mut self, f: &mut Frame) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(0)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(f.area());
let header_chunk = main_chunks[0];
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.margin(0)
.constraints([Constraint::Length(20), Constraint::Min(0)].as_ref())
.split(main_chunks[1]);
let menu_chunk = body_chunks[0];
let screen_chunk = body_chunks[1];
let network = self.network;
let title = format!("♆ neptune-dashboard — {network}");
let title = Line::from(title);
let title_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Black)
.fg(Color::Cyan);
let title_widget = Paragraph::new(Text::from(title)).style(title_style);
f.render_widget(title_widget, header_chunk);
let default_style = Style::default().bg(Color::Black).fg(Color::Gray);
let focus_style = default_style.fg(Color::LightCyan);
let selection_style = Style::default().fg(Color::Black).bg(Color::Gray);
let focus_selection_style = selection_style.bg(Color::LightCyan);
let menu_styles: Vec<_> = MenuItem::iter()
.map(|i| {
if i == self.current_menu_item && self.menu_in_focus {
focus_selection_style
} else if i == self.current_menu_item {
selection_style
} else if self.menu_in_focus {
focus_style
} else {
default_style
}
})
.collect();
let menu_items: Vec<ListItem> = MenuItem::iter()
.zip(menu_styles.iter())
.map(|(i, s)| ListItem::new(i.to_string()).style(*s))
.collect();
let menu = List::new(menu_items)
.style(match self.menu_in_focus {
true => focus_style,
false => default_style,
})
.block(Block::default().borders(Borders::ALL).title("Menu"));
f.render_widget(menu, menu_chunk);
match self.current_menu_item {
MenuItem::Overview => {
f.render_widget::<OverviewScreen>(
self.overview_screen.borrow().to_owned(),
screen_chunk,
);
}
MenuItem::Peers => {
f.render_widget::<PeersScreen>(self.peers_screen.borrow().to_owned(), screen_chunk);
}
MenuItem::Address => {
f.render_widget::<AddressScreen>(
self.address_screen.borrow().to_owned(),
screen_chunk,
);
}
MenuItem::History => {
f.render_widget::<HistoryScreen>(
self.history_screen.borrow().to_owned(),
screen_chunk,
);
}
MenuItem::Receive => {
f.render_widget::<ReceiveScreen>(
self.receive_screen.borrow().to_owned(),
screen_chunk,
);
}
MenuItem::Send => {
f.render_widget::<SendScreen>(self.send_screen.borrow().to_owned(), screen_chunk);
}
MenuItem::Mempool => {
f.render_widget::<MempoolScreen>(
self.mempool_screen.borrow().to_owned(),
screen_chunk,
);
}
MenuItem::Utxos => {
f.render_widget::<UtxosScreen>(self.utxos_screen.borrow().to_owned(), screen_chunk);
}
MenuItem::Quit => {
let messages: Vec<ListItem> = [
ListItem::new(Line::from(Span::raw("Press enter, `q`, or Esc to quit."))),
ListItem::new(Line::from(Span::raw("🌊"))),
]
.to_vec();
let messages = List::new(messages)
.style(match self.menu_in_focus {
true => default_style,
false => focus_style,
})
.block(Block::default().borders(Borders::ALL).title("Quit"));
f.render_widget(messages, screen_chunk);
}
};
}
}
impl fmt::Debug for DashboardApp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DashboardApp")
.field("running", &self.running)
.field("current_menu_item", &self.current_menu_item)
.field("menu_in_focus", &self.menu_in_focus)
.field("network", &self.network)
.finish()
}
}