From 6f0c9b9d3c048c7c86261234ca6402e21ccc264e Mon Sep 17 00:00:00 2001 From: iho Date: Fri, 10 Jul 2026 17:36:13 +0300 Subject: [PATCH 1/3] Migrate TUI from cursive to ratatui Replaces the cursive/pancurses-backed TUI with ratatui + crossterm, which has no external C dependency (no more linking libncurses). This fixes cross-compilation failures, macOS crashes on mouse clicks, and Windows crashes on first run reported in #3841. The cursive retained-mode widget tree is replaced with a single App struct (src/bin/tui/app.rs) that every screen renders from each frame. All previous behavior (status fields, peer/mining table columns and formatting, bottom-anchored log view, keybindings, Controller's public API) is preserved, with a few additions: - Mouse support: click a menu item to switch tabs, scroll wheel moves table selection (safe now that ncurses is gone) - PageUp/PageDown/Home/End navigation in tables, with selection clamped to the table's row count - Redraws are throttled: the UI only repaints on input or new data, with a 250ms cap to keep time-based fields fresh Interactive column sorting (cursive_table_view) is dropped since ratatui's Table has no equivalent; tables render in the order ServerStats provides. Fixes #3841 --- Cargo.toml | 8 +- src/bin/tui/app.rs | 152 +++++++++++ src/bin/tui/constants.rs | 66 ++--- src/bin/tui/logs.rs | 157 +++++------ src/bin/tui/menu.rs | 101 +++----- src/bin/tui/mining.rs | 543 ++++++++++++++------------------------- src/bin/tui/mod.rs | 1 + src/bin/tui/peers.rs | 265 +++++++------------ src/bin/tui/status.rs | 523 +++++++++++++++---------------------- src/bin/tui/types.rs | 9 - src/bin/tui/ui.rs | 404 +++++++++++++++++++---------- src/bin/tui/version.rs | 33 +-- 12 files changed, 1080 insertions(+), 1182 deletions(-) create mode 100644 src/bin/tui/app.rs diff --git a/Cargo.toml b/Cargo.toml index 7aba02e8d3..a13dcb6a86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,8 @@ chrono = "0.4.11" thiserror = "1" clap = { version = "2.33", features = ["yaml"] } ctrlc = { version = "3.1", features = ["termination"] } -cursive_table_view = "0.15.0" +ratatui = "0.29" +crossterm = "0.28" humansize = "1.1.0" serde = "1" serde_derive = "1" @@ -42,11 +43,6 @@ grin_servers = { path = "./servers", version = "5.5.1-alpha.0" } grin_util = { path = "./util", version = "5.5.1-alpha.0" } grin_store = { path = "./store", version = "5.5.1-alpha.0" } -[dependencies.cursive] -version = "0.21" -default-features = false -features = ["crossterm-backend"] - [build-dependencies] built = { version = "0.8.0", features = ["git2"]} diff --git a/src/bin/tui/app.rs b/src/bin/tui/app.rs new file mode 100644 index 0000000000..cf924bc00b --- /dev/null +++ b/src/bin/tui/app.rs @@ -0,0 +1,152 @@ +// Copyright 2024 The Grin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Central application state for the ratatui-based TUI + +use crate::servers::ServerStats; +use grin_util::logger::LogEntry; +use ratatui::layout::Rect; +use ratatui::widgets::TableState; +use std::collections::VecDeque; + +/// Number of log lines retained in the ring buffer +pub const LOG_BUFFER_SIZE: usize = 200; + +/// Top level tabs, in the order they appear in the side menu +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Tab { + Status, + Peers, + Mining, + Logs, + Version, +} + +impl Tab { + pub const ALL: [Tab; 5] = [ + Tab::Status, + Tab::Peers, + Tab::Mining, + Tab::Logs, + Tab::Version, + ]; + + pub fn title(&self) -> &'static str { + match self { + Tab::Status => "Basic Status", + Tab::Peers => "Peers and Sync", + Tab::Mining => "Mining", + Tab::Logs => "Logs", + Tab::Version => "Version Info", + } + } + + pub fn index(&self) -> usize { + Tab::ALL.iter().position(|t| t == self).unwrap_or(0) + } +} + +/// Which sub-screen of the Mining tab is showing +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum MiningSubview { + Workers, + Difficulty, +} + +/// Which pane currently receives key input +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Focus { + Menu, + Content, +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum DialogKind { + Info, + Error, +} + +pub struct Dialog { + pub text: String, + pub kind: DialogKind, +} + +/// All mutable state the UI renders from. Replaces the tree of named +/// cursive views with a single struct that every `draw` function reads. +pub struct App { + pub tab: Tab, + pub mining_subview: MiningSubview, + pub focus: Focus, + pub stats: Option, + pub logs: VecDeque, + pub peers_table: TableState, + pub mining_workers_table: TableState, + pub mining_diff_table: TableState, + pub dialog: Option, + pub should_quit: bool, + /// Screen area of the menu list, stored at draw time for mouse hit-testing + pub menu_area: Rect, +} + +impl App { + pub fn new() -> App { + App { + tab: Tab::Status, + mining_subview: MiningSubview::Workers, + focus: Focus::Menu, + stats: None, + logs: VecDeque::with_capacity(LOG_BUFFER_SIZE), + peers_table: TableState::default(), + mining_workers_table: TableState::default(), + mining_diff_table: TableState::default(), + dialog: None, + should_quit: false, + menu_area: Rect::default(), + } + } + + /// Number of rows in the table currently on screen, if any + pub fn current_table_len(&self) -> usize { + let stats = match &self.stats { + Some(s) => s, + None => return 0, + }; + match self.tab { + Tab::Peers => stats.peer_stats.len(), + Tab::Mining => match self.mining_subview { + MiningSubview::Workers => stats.stratum_stats.worker_stats.len(), + MiningSubview::Difficulty => stats.diff_stats.last_blocks.len(), + }, + _ => 0, + } + } + + pub fn push_log(&mut self, entry: LogEntry) { + self.logs.push_front(entry); + if self.logs.len() > LOG_BUFFER_SIZE { + self.logs.pop_back(); + } + } + + pub fn select_menu_next(&mut self) { + let next = (self.tab.index() + 1) % Tab::ALL.len(); + self.tab = Tab::ALL[next]; + } + + pub fn select_menu_prev(&mut self) { + let len = Tab::ALL.len(); + let prev = (self.tab.index() + len - 1) % len; + self.tab = Tab::ALL[prev]; + } +} diff --git a/src/bin/tui/constants.rs b/src/bin/tui/constants.rs index 75c31597ea..abb6b35cfc 100644 --- a/src/bin/tui/constants.rs +++ b/src/bin/tui/constants.rs @@ -12,55 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Identifiers for various TUI elements, because they may be referenced -//! from a few different places - -// Basic Status view -pub const VIEW_BASIC_STATUS: &str = "basic_status_view"; - -// Peer/Sync View -pub const VIEW_PEER_SYNC: &str = "peer_sync_view"; -pub const TABLE_PEER_STATUS: &str = "peer_status_table"; - -// Mining View -pub const VIEW_MINING: &str = "mining_view"; -pub const SUBMENU_MINING_BUTTON: &str = "mining_submenu_button"; -pub const TABLE_MINING_STATUS: &str = "mining_status_table"; -pub const TABLE_MINING_DIFF_STATUS: &str = "mining_diff_status_table"; - -// Logs View -pub const VIEW_LOGS: &str = "logs_view"; - -// Mining View -pub const VIEW_VERSION: &str = "version_view"; - -// Menu and root elements -pub const MAIN_MENU: &str = "main_menu"; -pub const ROOT_STACK: &str = "root_stack"; - // Logo (not final, to be used somewhere eventually -pub const _WELCOME_LOGO: &str = " GGGGG GGGGGGG - GGGGGGG GGGGGGGGG - GGGGGGGGG GGGG GGGGGGGGGG - GGGGGGGGGGG GGGGGGGG GGGGGGGGGGG - GGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGG - GGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGG - GGGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGG - GGGGGGGGGGGGGG GGGGGGGGGGGGGGGGGGGGGGGGGGGGG +pub const _WELCOME_LOGO: &str = " GGGGG GGGGGGG + GGGGGGG GGGGGGGGG + GGGGGGGGG GGGG GGGGGGGGGG + GGGGGGGGGGG GGGGGGGG GGGGGGGGGGG + GGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGG + GGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGG + GGGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGG + GGGGGGGGGGGGGG GGGGGGGGGGGGGGGGGGGGGGGGGGGGG GGGGGGGGGGGGGGG GGGGGGGGGGGGGGGGGGGGGGGGGGGGGG GGGGGGGGGGGGGGG GGGGGGGGGGGGGGGGGGGGGGGGGGGGGG - GGGGGG - GGGGGGG - GGGGGGGG + GGGGGG + GGGGGGG + GGGGGGGG GGGGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGGG GGGGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGGG - GGGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGGG - GGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGG - GGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGG - GGGGGGGGGGG GGGGGGGG GGGGGGGGGGGG - GGGGGGGGGG GGGGGGGG GGGGGGGGGGG - GGGGGGGG GGGGGGGG GGGGGGGGG - GGGGGGG GGGGGGGG GGGGGGG - GGGG GGGGGGGG GGGG - GG GGGGGGGG GG + GGGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGGG + GGGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGG + GGGGGGGGGGGG GGGGGGGG GGGGGGGGGGGGG + GGGGGGGGGGG GGGGGGGG GGGGGGGGGGGG + GGGGGGGGGG GGGGGGGG GGGGGGGGGGG + GGGGGGGG GGGGGGGG GGGGGGGGG + GGGGGGG GGGGGGGG GGGGGGG + GGGG GGGGGGGG GGGG + GG GGGGGGGG GG GGGGGGGG "; diff --git a/src/bin/tui/logs.rs b/src/bin/tui/logs.rs index 16e7d547eb..260c21439c 100644 --- a/src/bin/tui/logs.rs +++ b/src/bin/tui/logs.rs @@ -12,93 +12,104 @@ // See the License for the specific language governing permissions and // limitations under the License. -use cursive::theme::{BaseColor, Color, ColorStyle}; -use cursive::traits::Nameable; -use cursive::view::View; -use cursive::views::ResizedView; -use cursive::{Cursive, Printer}; +//! TUI log display: newest entries anchored to the bottom of the pane, +//! matching the behavior of the previous cursive-based log view. -use crate::tui::constants::VIEW_LOGS; -use cursive::utils::lines::spans::{LinesIterator, Row}; -use cursive::utils::markup::StyledString; -use grin_util::logger::LogEntry; -use log::Level; -use std::collections::VecDeque; - -pub struct TUILogsView; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; -impl TUILogsView { - pub fn create() -> impl View { - let logs_view = ResizedView::with_full_screen(LogBufferView::new(200).with_name("logs")); - logs_view.with_name(VIEW_LOGS) - } +use crate::tui::app::App; +use log::Level; - pub fn update(c: &mut Cursive, entry: LogEntry) { - c.call_on_name("logs", |t: &mut LogBufferView| { - t.update(entry); - }); +fn color(level: Level) -> Color { + match level { + Level::Info => Color::Green, + Level::Warn => Color::Yellow, + Level::Error => Color::Red, + _ => Color::White, } } -struct LogBufferView { - buffer: VecDeque, +/// Word-wraps `text` to `width` columns, hard-breaking words that don't fit +/// on their own. Empty input produces a single empty line. +fn wrap_text(text: &str, width: usize) -> Vec { + let width = width.max(1); + let mut out = Vec::new(); + for raw_line in text.split('\n') { + let mut current = String::new(); + let mut current_len = 0usize; + for word in raw_line.split(' ') { + let mut word_chars: Vec = word.chars().collect(); + while word_chars.len() > width { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + current_len = 0; + } + let rest = word_chars.split_off(width); + out.push(word_chars.into_iter().collect()); + word_chars = rest; + } + let word_len = word_chars.len(); + let needed = word_len + if current.is_empty() { 0 } else { 1 }; + if current_len + needed > width && !current.is_empty() { + out.push(std::mem::take(&mut current)); + current_len = 0; + } + if !current.is_empty() { + current.push(' '); + current_len += 1; + } + current.push_str(&word_chars.into_iter().collect::()); + current_len += word_len; + } + out.push(current); + } + out } -impl LogBufferView { - fn new(size: usize) -> Self { - let mut buffer = VecDeque::new(); - buffer.resize( - size, - LogEntry { - log: String::new(), - level: Level::Info, - }, - ); +/// Draw the logs view, bottom-anchoring the newest log lines. +pub fn draw(f: &mut Frame, area: Rect, app: &App) { + let width = area.width as usize; + let height = area.height as usize; - LogBufferView { buffer } + // Walk entries newest-first, wrapping each until we have enough rows to + // fill the pane, keeping each entry's own lines in the collected block. + let mut blocks: Vec<(Vec, Level)> = Vec::new(); + let mut rows_collected = 0usize; + for entry in app.logs.iter() { + if rows_collected >= height { + break; + } + let wrapped = wrap_text(entry.log.trim_end_matches('\n'), width); + rows_collected += wrapped.len(); + blocks.push((wrapped, entry.level)); } - fn update(&mut self, entry: LogEntry) { - self.buffer.push_front(entry); - self.buffer.pop_back(); - } + // Blocks are newest-first; reverse so oldest is at the top, newest at + // the bottom, matching the old bottom-anchored view. + blocks.reverse(); - fn color(level: Level) -> ColorStyle { - match level { - Level::Info => ColorStyle::new( - Color::Light(BaseColor::Green), - Color::Dark(BaseColor::Black), - ), - Level::Warn => ColorStyle::new( - Color::Light(BaseColor::Yellow), - Color::Dark(BaseColor::Black), - ), - Level::Error => { - ColorStyle::new(Color::Light(BaseColor::Red), Color::Dark(BaseColor::Black)) - } - _ => ColorStyle::new( - Color::Light(BaseColor::White), - Color::Dark(BaseColor::Black), - ), + let mut lines: Vec = Vec::new(); + for (wrapped, level) in &blocks { + for row in wrapped { + lines.push(Line::from(Span::styled(row.clone(), color(*level)))); } } -} -impl View for LogBufferView { - fn draw(&self, printer: &Printer) { - let mut i = 0; - for entry in self.buffer.iter().take(printer.size.y) { - printer.with_color(LogBufferView::color(entry.level), |p| { - let log_message = StyledString::plain(entry.log.as_str()); - let mut rows: Vec = LinesIterator::new(&log_message, printer.size.x).collect(); - rows.reverse(); // So stack traces are in the right order. - for row in rows { - for span in row.resolve(&log_message) { - p.print((0, p.size.y.saturating_sub(i + 1)), span.content); - i += 1; - } - } - }); - } + // If the newest block overflowed the pane, keep only the tail. + if lines.len() > height { + lines.drain(0..lines.len() - height); } + + // Pad with blank lines at the top so short logs still anchor to the + // bottom of the pane. + let pad = height.saturating_sub(lines.len()); + let mut padded = Vec::with_capacity(height); + padded.resize_with(pad, || Line::from("")); + padded.extend(lines); + + f.render_widget(Paragraph::new(padded), area); } diff --git a/src/bin/tui/menu.rs b/src/bin/tui/menu.rs index 41de91adf3..f2bf83e1df 100644 --- a/src/bin/tui/menu.rs +++ b/src/bin/tui/menu.rs @@ -14,74 +14,43 @@ //! Main Menu definition -use cursive::align::HAlign; -use cursive::direction::Orientation; -use cursive::event::Key; -use cursive::view::Nameable; -use cursive::view::View; -use cursive::views::{ - LinearLayout, OnEventView, ResizedView, SelectView, StackView, TextView, ViewRef, -}; -use cursive::Cursive; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{List, ListItem, Paragraph}; +use ratatui::Frame; -use crate::tui::constants::{ - MAIN_MENU, ROOT_STACK, SUBMENU_MINING_BUTTON, VIEW_BASIC_STATUS, VIEW_LOGS, VIEW_MINING, - VIEW_PEER_SYNC, VIEW_VERSION, -}; +use crate::tui::app::{App, Focus, Tab}; -pub fn create() -> impl View { - let mut main_menu = SelectView::new().h_align(HAlign::Left).with_name(MAIN_MENU); - main_menu - .get_mut() - .add_item("Basic Status", VIEW_BASIC_STATUS); - main_menu - .get_mut() - .add_item("Peers and Sync", VIEW_PEER_SYNC); - main_menu.get_mut().add_item("Mining", VIEW_MINING); - main_menu.get_mut().add_item("Logs", VIEW_LOGS); - main_menu.get_mut().add_item("Version Info", VIEW_VERSION); - let change_view = |s: &mut Cursive, v: &&str| { - if *v == "" { - return; - } +/// Draw the main menu (tab list) and its keybinding hints +pub fn draw(f: &mut Frame, area: Rect, app: &mut App) { + let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(5)]).split(area); + app.menu_area = chunks[0]; - let _ = s.call_on_name(ROOT_STACK, |sv: &mut StackView| { - let pos = sv.find_layer_from_name(v).unwrap(); - sv.move_to_front(pos); - }); - }; - - main_menu.get_mut().set_on_select(change_view); - main_menu - .get_mut() - .set_on_submit(|c: &mut Cursive, v: &str| { - if v == VIEW_MINING { - let _ = c.focus_name(SUBMENU_MINING_BUTTON); - } - }); - let main_menu = OnEventView::new(main_menu) - .on_pre_event('j', move |c| { - let mut s: ViewRef> = c.find_name(MAIN_MENU).unwrap(); - s.select_down(1)(c); - }) - .on_pre_event('k', move |c| { - let mut s: ViewRef> = c.find_name(MAIN_MENU).unwrap(); - s.select_up(1)(c); - }) - .on_pre_event(Key::Tab, move |c| { - let mut s: ViewRef> = c.find_name(MAIN_MENU).unwrap(); - if s.selected_id().unwrap() == s.len() - 1 { - s.set_selection(0)(c); + let items: Vec = Tab::ALL + .iter() + .map(|t| { + let style = if *t == app.tab { + let base = Style::default().add_modifier(Modifier::BOLD); + if app.focus == Focus::Menu { + base.fg(Color::Cyan) + } else { + base.fg(Color::Blue) + } } else { - s.select_down(1)(c); - } - }); - let main_menu = LinearLayout::new(Orientation::Vertical) - .child(ResizedView::with_full_height(main_menu)) - .child(TextView::new("------------------")) - .child(TextView::new("Tab/Arrow : Cycle ")) - .child(TextView::new("Enter : Select")) - .child(TextView::new("Esc : Back ")) - .child(TextView::new("Q : Quit ")); - main_menu + Style::default() + }; + ListItem::new(t.title()).style(style) + }) + .collect(); + f.render_widget(List::new(items), chunks[0]); + + let hints = vec![ + Line::from("------------------"), + Line::from("Tab/Arrow : Cycle "), + Line::from("Enter : Select"), + Line::from("Esc : Back "), + Line::from("Q : Quit "), + ]; + f.render_widget(Paragraph::new(hints), chunks[1]); } diff --git a/src/bin/tui/mining.rs b/src/bin/tui/mining.rs index d9ee15519f..dc4d1bf60f 100644 --- a/src/bin/tui/mining.rs +++ b/src/bin/tui/mining.rs @@ -14,371 +14,216 @@ //! Mining status view definition -use std::cmp::Ordering; - use chrono::prelude::{DateTime, Utc}; -use cursive::direction::Orientation; -use cursive::event::Key; -use cursive::traits::{Nameable, Resizable}; -use cursive::view::View; -use cursive::views::{ - Button, Dialog, LinearLayout, OnEventView, Panel, ResizedView, StackView, TextView, -}; -use cursive::Cursive; use std::time; -use crate::tui::constants::{ - MAIN_MENU, SUBMENU_MINING_BUTTON, TABLE_MINING_DIFF_STATUS, TABLE_MINING_STATUS, VIEW_MINING, -}; -use crate::tui::types::TUIStatusListener; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; +use ratatui::Frame; use crate::servers::{DiffBlock, ServerStats, WorkerStats}; -use cursive_table_view::{TableView, TableViewItem}; - -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -enum StratumWorkerColumn { - Id, - IsConnected, - LastSeen, - PowDifficulty, - NumAccepted, - NumRejected, - NumStale, - NumBlocksFound, -} - -impl StratumWorkerColumn { - fn _as_str(&self) -> &str { - match *self { - StratumWorkerColumn::Id => "ID", - StratumWorkerColumn::IsConnected => "Connected", - StratumWorkerColumn::LastSeen => "Last Seen", - StratumWorkerColumn::PowDifficulty => "PowDifficulty", - StratumWorkerColumn::NumAccepted => "Num Accepted", - StratumWorkerColumn::NumRejected => "Num Rejected", - StratumWorkerColumn::NumStale => "Num Stale", - StratumWorkerColumn::NumBlocksFound => "Blocks Found", - } - } +use crate::tui::app::{App, MiningSubview}; +use ratatui::widgets::TableState; + +fn worker_row(w: &WorkerStats) -> Row<'static> { + let naive_datetime = DateTime::::from_timestamp( + w.last_seen + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + 0, + ) + .unwrap_or_default() + .naive_utc(); + let datetime: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); + + Row::new(vec![ + Cell::from(w.id.clone()), + Cell::from(w.is_connected.to_string()), + Cell::from(datetime.to_string()), + Cell::from(w.pow_difficulty.to_string()), + Cell::from(w.num_accepted.to_string()), + Cell::from(w.num_rejected.to_string()), + Cell::from(w.num_stale.to_string()), + Cell::from(w.num_blocks_found.to_string()), + ]) } -impl TableViewItem for WorkerStats { - fn to_column(&self, column: StratumWorkerColumn) -> String { - let naive_datetime = DateTime::::from_timestamp( - self.last_seen - .duration_since(time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - 0, - ) +const WORKER_HEADERS: [&str; 8] = [ + "ID", + "Connected", + "Last Seen", + "Difficulty", + "Accepted", + "Rejected", + "Stale", + "Blocks Found", +]; +const WORKER_WIDTHS: [u16; 8] = [6, 14, 20, 10, 5, 5, 5, 35]; + +fn diff_row(d: &DiffBlock) -> Row<'static> { + let naive_datetime = DateTime::::from_timestamp(d.time as i64, 0) .unwrap_or_default() .naive_utc(); - let datetime: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); - - match column { - StratumWorkerColumn::Id => self.id.clone(), - StratumWorkerColumn::IsConnected => self.is_connected.to_string(), - StratumWorkerColumn::LastSeen => datetime.to_string(), - StratumWorkerColumn::PowDifficulty => self.pow_difficulty.to_string(), - StratumWorkerColumn::NumAccepted => self.num_accepted.to_string(), - StratumWorkerColumn::NumRejected => self.num_rejected.to_string(), - StratumWorkerColumn::NumStale => self.num_stale.to_string(), - StratumWorkerColumn::NumBlocksFound => self.num_blocks_found.to_string(), - } - } - - fn cmp(&self, other: &Self, column: StratumWorkerColumn) -> Ordering - where - Self: Sized, - { - match column { - StratumWorkerColumn::Id => self.id.cmp(&other.id), - StratumWorkerColumn::IsConnected => self.is_connected.cmp(&other.is_connected), - StratumWorkerColumn::LastSeen => self.last_seen.cmp(&other.last_seen), - StratumWorkerColumn::PowDifficulty => self.pow_difficulty.cmp(&other.pow_difficulty), - StratumWorkerColumn::NumAccepted => self.num_accepted.cmp(&other.num_accepted), - StratumWorkerColumn::NumRejected => self.num_rejected.cmp(&other.num_rejected), - StratumWorkerColumn::NumStale => self.num_stale.cmp(&other.num_stale), - StratumWorkerColumn::NumBlocksFound => { - self.num_blocks_found.cmp(&other.num_blocks_found) - } - } - } -} -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -enum DiffColumn { - Height, - Hash, - Difficulty, - Time, - Duration, + let datetime: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); + + Row::new(vec![ + Cell::from(d.block_height.to_string()), + Cell::from(d.block_hash.to_string()), + Cell::from(d.difficulty.to_string()), + Cell::from(format!("{}", datetime)), + Cell::from(format!("{}s", d.duration)), + ]) } -impl DiffColumn { - fn _as_str(&self) -> &str { - match *self { - DiffColumn::Height => "Height", - DiffColumn::Hash => "Hash", - DiffColumn::Difficulty => "Network Difficulty", - DiffColumn::Time => "Block Time", - DiffColumn::Duration => "Duration", - } +const DIFF_HEADERS: [&str; 5] = [ + "Height", + "Hash", + "Network Difficulty", + "Block Time", + "Duration", +]; +const DIFF_WIDTHS: [u16; 5] = [15, 15, 15, 30, 25]; + +/// Draw the mining view +pub fn draw(f: &mut Frame, area: Rect, app: &mut App) { + let App { + stats, + mining_subview, + mining_workers_table, + mining_diff_table, + .. + } = app; + let stats = stats.as_ref(); + + let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area); + + let submenu = Line::from(vec![ + ratatui::text::Span::styled( + " Mining Server Status ", + if *mining_subview == MiningSubview::Workers { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }, + ), + ratatui::text::Span::raw(" "), + ratatui::text::Span::styled( + " Difficulty ", + if *mining_subview == MiningSubview::Difficulty { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }, + ), + ratatui::text::Span::raw(" (w: workers, d: difficulty)"), + ]); + f.render_widget(Paragraph::new(submenu), chunks[0]); + + match mining_subview { + MiningSubview::Workers => draw_workers(f, chunks[1], stats, mining_workers_table), + MiningSubview::Difficulty => draw_difficulty(f, chunks[1], stats, mining_diff_table), } } -impl TableViewItem for DiffBlock { - fn to_column(&self, column: DiffColumn) -> String { - let naive_datetime = DateTime::::from_timestamp(self.time as i64, 0) - .unwrap_or_default() - .naive_utc(); - let datetime: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); - - match column { - DiffColumn::Height => self.block_height.to_string(), - DiffColumn::Hash => self.block_hash.to_string(), - DiffColumn::Difficulty => self.difficulty.to_string(), - DiffColumn::Time => format!("{}", datetime), - DiffColumn::Duration => format!("{}s", self.duration), - } - } - - fn cmp(&self, other: &Self, column: DiffColumn) -> Ordering - where - Self: Sized, - { - match column { - DiffColumn::Height => self.block_height.cmp(&other.block_height), - DiffColumn::Hash => self.block_hash.cmp(&other.block_hash), - DiffColumn::Difficulty => self.difficulty.cmp(&other.difficulty), - DiffColumn::Time => self.time.cmp(&other.time), - DiffColumn::Duration => self.duration.cmp(&other.duration), +fn draw_workers(f: &mut Frame, area: Rect, stats: Option<&ServerStats>, state: &mut TableState) { + let chunks = Layout::vertical([Constraint::Length(7), Constraint::Min(0)]).split(area); + + let lines = match stats { + Some(stats) => { + let s = &stats.stratum_stats; + let block_height = if s.num_workers == 0 { + "Solving Block Height: n/a".to_string() + } else { + format!("Solving Block Height: {}", s.block_height) + }; + let network_difficulty = if s.num_workers == 0 { + "Network Difficulty: n/a".to_string() + } else { + format!("Network Difficulty: {}", s.network_difficulty) + }; + let network_hashrate = if s.num_workers == 0 { + "Network Hashrate: n/a".to_string() + } else { + format!( + "Network Hashrate C{}: {:.2}", + s.edge_bits, s.network_hashrate + ) + }; + vec![ + Line::from(format!("Mining server enabled: {}", s.is_enabled)), + Line::from(format!("Mining server running: {}", s.is_running)), + Line::from(format!("Active workers: {}", s.num_workers)), + Line::from(block_height), + Line::from(format!("Blocks Found: {}", s.blocks_found)), + Line::from(network_difficulty), + Line::from(network_hashrate), + ] } - } + None => (0..7).map(|_| Line::from("")).collect(), + }; + f.render_widget(Paragraph::new(lines), chunks[0]); + + let workers: &[WorkerStats] = stats + .map(|s| s.stratum_stats.worker_stats.as_slice()) + .unwrap_or(&[]); + let rows: Vec = workers.iter().map(worker_row).collect(); + let widths: Vec = WORKER_WIDTHS + .iter() + .map(|w| Constraint::Percentage(*w)) + .collect(); + let table = Table::new(rows, widths) + .header(Row::new(WORKER_HEADERS.to_vec())) + .block( + Block::default() + .borders(Borders::ALL) + .title("Mining Workers"), + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + f.render_stateful_widget(table, chunks[1], state); } -/// Mining status view -pub struct TUIMiningView; - -impl TUIMiningView { - /// Create the mining view - pub fn create() -> impl View { - let devices_button = Button::new_raw("Mining Server Status", |s| { - let _ = s.call_on_name("mining_stack_view", |sv: &mut StackView| { - let pos = sv.find_layer_from_name("mining_device_view").unwrap(); - sv.move_to_front(pos); - }); - }) - .with_name(SUBMENU_MINING_BUTTON); - let difficulty_button = Button::new_raw("Difficulty", |s| { - let _ = s.call_on_name("mining_stack_view", |sv: &mut StackView| { - let pos = sv.find_layer_from_name("mining_difficulty_view").unwrap(); - sv.move_to_front(pos); - }); - }); - let mining_submenu = LinearLayout::new(Orientation::Horizontal) - .child(Panel::new(devices_button)) - .child(Panel::new(difficulty_button)); - - let mut table_view = TableView::::new() - .column(StratumWorkerColumn::Id, "ID", |c| c.width_percent(6)) - .column(StratumWorkerColumn::IsConnected, "Connected", |c| { - c.width_percent(14) - }) - .column(StratumWorkerColumn::LastSeen, "Last Seen", |c| { - c.width_percent(20) - }) - .column(StratumWorkerColumn::PowDifficulty, "Difficulty", |c| { - c.width_percent(10) - }) - .column(StratumWorkerColumn::NumAccepted, "Accepted", |c| { - c.width_percent(5) - }) - .column(StratumWorkerColumn::NumRejected, "Rejected", |c| { - c.width_percent(5) - }) - .column(StratumWorkerColumn::NumStale, "Stale", |c| { - c.width_percent(5) - }) - .column(StratumWorkerColumn::NumBlocksFound, "Blocks Found", |c| { - c.width_percent(35) - }) - .default_column(StratumWorkerColumn::IsConnected); - table_view.sort_by(StratumWorkerColumn::IsConnected, Ordering::Greater); - - let status_view = LinearLayout::new(Orientation::Vertical) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_config_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_is_running_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_num_workers_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_block_height_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_blocks_found_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_network_difficulty_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("stratum_network_hashrate")), - ); - - let mining_device_view = LinearLayout::new(Orientation::Vertical) - .child(status_view) - .child(ResizedView::with_full_screen( - Dialog::around(table_view.with_name(TABLE_MINING_STATUS).min_size((50, 20))) - .title("Mining Workers"), - )) - .with_name("mining_device_view"); - - let diff_status_view = LinearLayout::new(Orientation::Vertical) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Tip Height: ")) - .child(TextView::new("").with_name("diff_cur_height")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Difficulty Adjustment Window: ")) - .child(TextView::new("").with_name("diff_adjust_window")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Average Block Time: ")) - .child(TextView::new("").with_name("diff_avg_block_time")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Average Difficulty: ")) - .child(TextView::new("").with_name("diff_avg_difficulty")), - ); - let diff_table_view = TableView::::new() - .column(DiffColumn::Height, "Height", |c| c.width_percent(15)) - .column(DiffColumn::Hash, "Hash", |c| c.width_percent(15)) - .column(DiffColumn::Difficulty, "Network Difficulty", |c| { - c.width_percent(15) - }) - .column(DiffColumn::Time, "Block Time", |c| c.width_percent(30)) - .column(DiffColumn::Duration, "Duration", |c| c.width_percent(25)) - .default_column(DiffColumn::Height); - - let mining_difficulty_view = LinearLayout::new(Orientation::Vertical) - .child(diff_status_view) - .child(ResizedView::with_full_screen( - Dialog::around( - diff_table_view - .with_name(TABLE_MINING_DIFF_STATUS) - .min_size((50, 20)), - ) +fn draw_difficulty(f: &mut Frame, area: Rect, stats: Option<&ServerStats>, state: &mut TableState) { + let chunks = Layout::vertical([Constraint::Length(4), Constraint::Min(0)]).split(area); + + let lines = match stats { + Some(stats) => { + let d = &stats.diff_stats; + let dur = time::Duration::from_secs(d.average_block_time); + vec![ + Line::from(format!("Tip Height: {}", d.height)), + Line::from(format!("Difficulty Adjustment Window: {}", d.window_size)), + Line::from(format!("Average Block Time: {} Secs", dur.as_secs())), + Line::from(format!("Average Difficulty: {}", d.average_difficulty)), + ] + } + None => vec![ + Line::from(""), + Line::from(""), + Line::from(""), + Line::from(""), + ], + }; + f.render_widget(Paragraph::new(lines), chunks[0]); + + let diff_blocks: &[DiffBlock] = stats + .map(|s| s.diff_stats.last_blocks.as_slice()) + .unwrap_or(&[]); + // Newest block first, matching the previous view's reversed ordering + let rows: Vec = diff_blocks.iter().rev().map(diff_row).collect(); + let widths: Vec = DIFF_WIDTHS + .iter() + .map(|w| Constraint::Percentage(*w)) + .collect(); + let table = Table::new(rows, widths) + .header(Row::new(DIFF_HEADERS.to_vec())) + .block( + Block::default() + .borders(Borders::ALL) .title("Mining Difficulty Data"), - )) - .with_name("mining_difficulty_view"); - - let view_stack = StackView::new() - .layer(mining_difficulty_view) - .layer(mining_device_view) - .with_name("mining_stack_view"); - - let mining_view = LinearLayout::new(Orientation::Vertical) - .child(mining_submenu) - .child(view_stack); - - let mining_view = OnEventView::new(mining_view).on_pre_event(Key::Esc, move |c| { - let _ = c.focus_name(MAIN_MENU); - }); - - mining_view.with_name(VIEW_MINING) - } -} - -impl TUIStatusListener for TUIMiningView { - /// update - fn update(c: &mut Cursive, stats: &ServerStats) { - c.call_on_name("diff_cur_height", |t: &mut TextView| { - t.set_content(stats.diff_stats.height.to_string()); - }); - c.call_on_name("diff_adjust_window", |t: &mut TextView| { - t.set_content(stats.diff_stats.window_size.to_string()); - }); - let dur = time::Duration::from_secs(stats.diff_stats.average_block_time); - c.call_on_name("diff_avg_block_time", |t: &mut TextView| { - t.set_content(format!("{} Secs", dur.as_secs())); - }); - c.call_on_name("diff_avg_difficulty", |t: &mut TextView| { - t.set_content(stats.diff_stats.average_difficulty.to_string()); - }); - - let mut diff_stats = stats.diff_stats.last_blocks.clone(); - diff_stats.reverse(); - let _ = c.call_on_name( - TABLE_MINING_DIFF_STATUS, - |t: &mut TableView| { - t.set_items(diff_stats); - }, - ); - let stratum_stats = stats.stratum_stats.clone(); - let worker_stats = stratum_stats.worker_stats; - let stratum_enabled = format!("Mining server enabled: {}", stratum_stats.is_enabled); - let stratum_is_running = format!("Mining server running: {}", stratum_stats.is_running); - let stratum_num_workers = format!("Active workers: {}", stratum_stats.num_workers); - let stratum_blocks_found = format!("Blocks Found: {}", stratum_stats.blocks_found); - let stratum_block_height = match stratum_stats.num_workers { - 0 => "Solving Block Height: n/a".to_string(), - _ => format!("Solving Block Height: {}", stratum_stats.block_height), - }; - let stratum_network_difficulty = match stratum_stats.num_workers { - 0 => "Network Difficulty: n/a".to_string(), - _ => format!( - "Network Difficulty: {}", - stratum_stats.network_difficulty - ), - }; - let stratum_network_hashrate = match stratum_stats.num_workers { - 0 => "Network Hashrate: n/a".to_string(), - _ => format!( - "Network Hashrate C{}: {:.*}", - stratum_stats.edge_bits, 2, stratum_stats.network_hashrate - ), - }; - - c.call_on_name("stratum_config_status", |t: &mut TextView| { - t.set_content(stratum_enabled); - }); - c.call_on_name("stratum_is_running_status", |t: &mut TextView| { - t.set_content(stratum_is_running); - }); - c.call_on_name("stratum_num_workers_status", |t: &mut TextView| { - t.set_content(stratum_num_workers); - }); - c.call_on_name("stratum_blocks_found_status", |t: &mut TextView| { - t.set_content(stratum_blocks_found); - }); - c.call_on_name("stratum_block_height_status", |t: &mut TextView| { - t.set_content(stratum_block_height); - }); - c.call_on_name("stratum_network_difficulty_status", |t: &mut TextView| { - t.set_content(stratum_network_difficulty); - }); - c.call_on_name("stratum_network_hashrate", |t: &mut TextView| { - t.set_content(stratum_network_hashrate); - }); - let _ = c.call_on_name( - TABLE_MINING_STATUS, - |t: &mut TableView| { - t.set_items_stable(worker_stats); - }, - ); - } + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + f.render_stateful_widget(table, chunks[1], state); } diff --git a/src/bin/tui/mod.rs b/src/bin/tui/mod.rs index 2cdf0c8865..0d01d3f163 100644 --- a/src/bin/tui/mod.rs +++ b/src/bin/tui/mod.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod app; mod constants; mod logs; mod menu; diff --git a/src/bin/tui/peers.rs b/src/bin/tui/peers.rs index a277508a50..3831810585 100644 --- a/src/bin/tui/peers.rs +++ b/src/bin/tui/peers.rs @@ -14,170 +14,77 @@ //! TUI peer display -use std::cmp::Ordering; - -use crate::servers::{PeerStats, ServerStats}; +use crate::servers::PeerStats; use chrono::prelude::*; use humansize::{file_size_opts::CONVENTIONAL, FileSize}; -use cursive::direction::Orientation; -use cursive::event::Key; -use cursive::traits::{Nameable, Resizable}; -use cursive::view::View; -use cursive::views::{Dialog, LinearLayout, OnEventView, ResizedView, TextView}; -use cursive::Cursive; - -use crate::tui::constants::{MAIN_MENU, TABLE_PEER_STATUS, VIEW_PEER_SYNC}; -use crate::tui::types::TUIStatusListener; -use cursive_table_view::{TableView, TableViewItem}; - -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -pub enum PeerColumn { - Address, - State, - UsedBandwidth, - TotalDifficulty, - Direction, - Version, - UserAgent, - Capabilities, -} - -impl PeerColumn { - fn _as_str(&self) -> &str { - match *self { - PeerColumn::Address => "Address", - PeerColumn::State => "State", - PeerColumn::UsedBandwidth => "Used bandwidth", - PeerColumn::Version => "Version", - PeerColumn::TotalDifficulty => "Total Difficulty", - PeerColumn::Direction => "Direction", - PeerColumn::UserAgent => "User Agent", - PeerColumn::Capabilities => "Capabilities", - } - } -} +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; +use ratatui::Frame; -impl TableViewItem for PeerStats { - fn to_column(&self, column: PeerColumn) -> String { - // Converts optional size to human readable size - fn size_to_string(size: u64) -> String { - size.file_size(CONVENTIONAL) - .unwrap_or_else(|_| "-".to_string()) - } - - match column { - PeerColumn::Address => self.addr.clone(), - PeerColumn::State => self.state.clone(), - PeerColumn::UsedBandwidth => format!( - "↑: {}, ↓: {}", - size_to_string(self.sent_bytes_per_sec), - size_to_string(self.received_bytes_per_sec), - ), - PeerColumn::TotalDifficulty => format!( - "{} D @ {} H ({}s)", - self.total_difficulty, - self.height, - (Utc::now() - self.last_seen).num_seconds(), - ), - PeerColumn::Direction => self.direction.clone(), - PeerColumn::Version => format!("{}", self.version), - PeerColumn::UserAgent => self.user_agent.clone(), - PeerColumn::Capabilities => format!("{}", self.capabilities.bits()), - } - } - - fn cmp(&self, other: &Self, column: PeerColumn) -> Ordering - where - Self: Sized, - { - // Compares used bandwidth of two peers - fn cmp_used_bandwidth(curr: &PeerStats, other: &PeerStats) -> Ordering { - let curr_recv_bytes = curr.received_bytes_per_sec; - let curr_sent_bytes = curr.sent_bytes_per_sec; - let other_recv_bytes = other.received_bytes_per_sec; - let other_sent_bytes = other.sent_bytes_per_sec; - - let curr_sum = curr_recv_bytes + curr_sent_bytes; - let other_sum = other_recv_bytes + other_sent_bytes; - - curr_sum.cmp(&other_sum) - } +use crate::tui::app::App; - let sort_by_addr = || self.addr.cmp(&other.addr); - - match column { - PeerColumn::Address => sort_by_addr(), - PeerColumn::State => self.state.cmp(&other.state).then(sort_by_addr()), - PeerColumn::UsedBandwidth => cmp_used_bandwidth(&self, &other).then(sort_by_addr()), - PeerColumn::TotalDifficulty => self - .total_difficulty - .cmp(&other.total_difficulty) - .then(sort_by_addr()), - PeerColumn::Direction => self.direction.cmp(&other.direction).then(sort_by_addr()), - PeerColumn::Version => self.version.cmp(&other.version).then(sort_by_addr()), - PeerColumn::UserAgent => self.user_agent.cmp(&other.user_agent).then(sort_by_addr()), - PeerColumn::Capabilities => self - .capabilities - .cmp(&other.capabilities) - .then(sort_by_addr()), - } - } +// Converts a byte count to a human readable size +fn size_to_string(size: u64) -> String { + size.file_size(CONVENTIONAL) + .unwrap_or_else(|_| "-".to_string()) } -pub struct TUIPeerView; - -impl TUIPeerView { - pub fn create() -> impl View { - let table_view = TableView::::new() - .column(PeerColumn::Address, "Address", |c| c.width_percent(16)) - .column(PeerColumn::State, "State", |c| c.width_percent(8)) - .column(PeerColumn::UsedBandwidth, "Used bandwidth", |c| { - c.width_percent(16) - }) - .column(PeerColumn::Direction, "Direction", |c| c.width_percent(8)) - .column(PeerColumn::TotalDifficulty, "Total Difficulty", |c| { - c.width_percent(24) - }) - .column(PeerColumn::Version, "Proto", |c| c.width_percent(4)) - .column(PeerColumn::Capabilities, "Capab", |c| c.width_percent(4)) - .column(PeerColumn::UserAgent, "User Agent", |c| c.width_percent(18)); - let peer_status_view = ResizedView::with_full_screen( - LinearLayout::new(Orientation::Vertical) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("peers_total")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Longest Chain: ")) - .child(TextView::new(" ").with_name("longest_work_peer")), - ) - .child(TextView::new(" ")) - .child( - Dialog::around(table_view.with_name(TABLE_PEER_STATUS).min_size((50, 20))) - .title("Connected Peers"), - ), - ) - .with_name(VIEW_PEER_SYNC); - - let peer_status_view = - OnEventView::new(peer_status_view).on_pre_event(Key::Esc, move |c| { - let _ = c.focus_name(MAIN_MENU); - }); - - peer_status_view - } +fn peer_row(p: &PeerStats) -> Row<'static> { + Row::new(vec![ + Cell::from(p.addr.clone()), + Cell::from(p.state.clone()), + Cell::from(format!( + "↑: {}, ↓: {}", + size_to_string(p.sent_bytes_per_sec), + size_to_string(p.received_bytes_per_sec), + )), + Cell::from(p.direction.clone()), + Cell::from(format!( + "{} D @ {} H ({}s)", + p.total_difficulty, + p.height, + (Utc::now() - p.last_seen).num_seconds(), + )), + Cell::from(format!("{}", p.version)), + Cell::from(format!("{}", p.capabilities.bits())), + Cell::from(p.user_agent.clone()), + ]) } -impl TUIStatusListener for TUIPeerView { - fn update(c: &mut Cursive, stats: &ServerStats) { +const HEADERS: [&str; 8] = [ + "Address", + "State", + "Used bandwidth", + "Direction", + "Total Difficulty", + "Proto", + "Capab", + "User Agent", +]; + +const WIDTHS: [u16; 8] = [16, 8, 16, 8, 24, 4, 4, 18]; + +/// Draw the peers/sync view +pub fn draw(f: &mut Frame, area: Rect, app: &mut App) { + let App { + stats, peers_table, .. + } = app; + + let peer_stats: &[PeerStats] = stats + .as_ref() + .map(|s| s.peer_stats.as_slice()) + .unwrap_or(&[]); + + let longest_work_peer = stats.as_ref().map(|stats| { let lp = stats .peer_stats .iter() .max_by(|x, y| x.total_difficulty.cmp(&y.total_difficulty)); - let lp_str = match lp { + match lp { Some(l) => format!( "{} D @ {} H vs Us: {} D @ {} H", l.total_difficulty, @@ -186,26 +93,40 @@ impl TUIStatusListener for TUIPeerView { stats.chain_stats.height ), None => "".to_string(), - }; - let _ = c.call_on_name( - TABLE_PEER_STATUS, - |t: &mut TableView| { - t.set_items_stable(stats.peer_stats.clone()); - }, - ); - let _ = c.call_on_name("peers_total", |t: &mut TextView| { - t.set_content(format!( - "Total Peers: {} (Outbound: {})", - stats.peer_stats.len(), - stats - .peer_stats - .iter() - .filter(|x| x.direction == "Outbound") - .count(), - )); - }); - let _ = c.call_on_name("longest_work_peer", |t: &mut TextView| { - t.set_content(lp_str); - }); - } + } + }); + + let outbound_count = peer_stats + .iter() + .filter(|x| x.direction == "Outbound") + .count(); + let total_line = format!( + "Total Peers: {} (Outbound: {})", + peer_stats.len(), + outbound_count + ); + + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); + + let header_lines = vec![ + Line::from(total_line), + Line::from(format!( + "Longest Chain: {}", + longest_work_peer.unwrap_or_default() + )), + ]; + f.render_widget(Paragraph::new(header_lines), chunks[0]); + + let rows: Vec = peer_stats.iter().map(peer_row).collect(); + let widths: Vec = WIDTHS.iter().map(|w| Constraint::Percentage(*w)).collect(); + let table = Table::new(rows, widths) + .header(Row::new(HEADERS.to_vec())) + .block( + Block::default() + .borders(Borders::ALL) + .title("Connected Peers"), + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + f.render_stateful_widget(table, chunks[1], peers_table); } diff --git a/src/bin/tui/status.rs b/src/bin/tui/status.rs index 69b43d3b8e..adff972535 100644 --- a/src/bin/tui/status.rs +++ b/src/bin/tui/status.rs @@ -15,339 +15,240 @@ //! Basic status view definition use chrono::prelude::Utc; -use cursive::direction::Orientation; -use cursive::traits::Nameable; -use cursive::view::View; -use cursive::views::{LinearLayout, ResizedView, TextView}; -use cursive::Cursive; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::Frame; use std::borrow::Cow; -use crate::tui::constants::VIEW_BASIC_STATUS; -use crate::tui::types::TUIStatusListener; +use crate::tui::app::App; use crate::chain::{HeaderSyncMode, SyncStatus}; -use crate::servers::ServerStats; const NANO_TO_MILLIS: f64 = 1.0 / 1_000_000.0; -pub struct TUIStatusView; +pub fn update_sync_status(sync_status: SyncStatus) -> Cow<'static, str> { + match sync_status { + SyncStatus::Initial => Cow::Borrowed("Initializing"), + SyncStatus::NoSync => Cow::Borrowed("Running"), + SyncStatus::AwaitingPeers(_) => Cow::Borrowed("Waiting for peers"), + SyncStatus::HeaderSync { + sync_head, + sync_mode, + highest_height, + .. + } => { + let percent = if highest_height == 0 { + 0 + } else { + sync_head.height * 100 / highest_height + }; + let sync_mode = match sync_mode { + HeaderSyncMode::Legacy => "Legacy", + HeaderSyncMode::Pihd => "PIHD", + }; + Cow::Owned(format!( + "Sync step 1/7: Downloading headers ({}) - {}%", + sync_mode, percent + )) + } + SyncStatus::TxHashsetPibd { + aborted: _, + errored: _, + completed_leaves, + leaves_required, + completed_to_height: _, + required_height: _, + } => { + let percent = if completed_leaves == 0 { + 0 + } else { + completed_leaves * 100 / leaves_required + }; + Cow::Owned(format!( + "Sync step 2/7: Downloading Tx state (PIBD) - {} / {} entries - {}%", + completed_leaves, leaves_required, percent + )) + } + SyncStatus::TxHashsetDownload(stat) => { + if stat.total_size > 0 { + let percent = stat.downloaded_size * 100 / stat.total_size; + let start = stat + .prev_update_time + .timestamp_nanos_opt() + .unwrap_or_default(); + let fin = Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let dur_ms = (fin - start) as f64 * NANO_TO_MILLIS; -impl TUIStatusView { - pub fn update_sync_status(sync_status: SyncStatus) -> Cow<'static, str> { - match sync_status { - SyncStatus::Initial => Cow::Borrowed("Initializing"), - SyncStatus::NoSync => Cow::Borrowed("Running"), - SyncStatus::AwaitingPeers(_) => Cow::Borrowed("Waiting for peers"), - SyncStatus::HeaderSync { - sync_head, - sync_mode, - highest_height, - .. - } => { - let percent = if highest_height == 0 { - 0 - } else { - sync_head.height * 100 / highest_height - }; - let sync_mode = match sync_mode { - HeaderSyncMode::Legacy => "Legacy", - HeaderSyncMode::Pihd => "PIHD", - }; - Cow::Owned(format!( - "Sync step 1/7: Downloading headers ({}) - {}%", - sync_mode, percent + Cow::Owned(format!("Sync step 2/7: Downloading {}(MB) chain state for state sync: {}% at {:.1?}(kB/s)", + stat.total_size / 1_000_000, + percent, + if dur_ms > 1.0f64 { stat.downloaded_size.saturating_sub(stat.prev_downloaded_size) as f64 / dur_ms as f64 } else { 0f64 }, )) - } - SyncStatus::TxHashsetPibd { - aborted: _, - errored: _, - completed_leaves, - leaves_required, - completed_to_height: _, - required_height: _, - } => { - let percent = if completed_leaves == 0 { - 0 - } else { - completed_leaves * 100 / leaves_required - }; - Cow::Owned(format!( - "Sync step 2/7: Downloading Tx state (PIBD) - {} / {} entries - {}%", - completed_leaves, leaves_required, percent - )) - } - SyncStatus::TxHashsetDownload(stat) => { - if stat.total_size > 0 { - let percent = stat.downloaded_size * 100 / stat.total_size; - let start = stat - .prev_update_time - .timestamp_nanos_opt() - .unwrap_or_default(); - let fin = Utc::now().timestamp_nanos_opt().unwrap_or_default(); - let dur_ms = (fin - start) as f64 * NANO_TO_MILLIS; + } else { + let start = stat.start_time.timestamp_millis(); + let fin = Utc::now().timestamp_millis(); + let dur_secs = (fin - start) / 1000; - Cow::Owned(format!("Sync step 2/7: Downloading {}(MB) chain state for state sync: {}% at {:.1?}(kB/s)", - stat.total_size / 1_000_000, - percent, - if dur_ms > 1.0f64 { stat.downloaded_size.saturating_sub(stat.prev_downloaded_size) as f64 / dur_ms as f64 } else { 0f64 }, - )) - } else { - let start = stat.start_time.timestamp_millis(); - let fin = Utc::now().timestamp_millis(); - let dur_secs = (fin - start) / 1000; - - Cow::Owned(format!("Sync step 2/7: Downloading chain state for state sync. Waiting remote peer to start: {}s", - dur_secs, - )) - } - } - SyncStatus::TxHashsetSetup { - headers, - headers_total, - kernel_pos, - kernel_pos_total, - } => { - if headers.is_some() && headers_total.is_some() { - let h = headers.unwrap(); - let ht = headers_total.unwrap(); - let percent = h * 100 / ht; - Cow::Owned(format!( - "Sync step 3/7: Preparing for validation (kernel history) - {}/{} - {}%", - h, ht, percent - )) - } else if kernel_pos.is_some() && kernel_pos_total.is_some() { - let k = kernel_pos.unwrap(); - let kt = kernel_pos_total.unwrap(); - let percent = k * 100 / kt; - Cow::Owned(format!( - "Sync step 3/7: Preparing for validation (kernel position) - {}/{} - {}%", - k, kt, percent - )) - } else { - Cow::Borrowed("Sync step 3/7: Preparing chain state for validation") - } + Cow::Owned(format!("Sync step 2/7: Downloading chain state for state sync. Waiting remote peer to start: {}s", + dur_secs, + )) } - SyncStatus::TxHashsetRangeProofsValidation { - rproofs, - rproofs_total, - } => { - let r_percent = if rproofs_total > 0 { - (rproofs * 100) / rproofs_total - } else { - 0 - }; + } + SyncStatus::TxHashsetSetup { + headers, + headers_total, + kernel_pos, + kernel_pos_total, + } => { + if headers.is_some() && headers_total.is_some() { + let h = headers.unwrap(); + let ht = headers_total.unwrap(); + let percent = h * 100 / ht; Cow::Owned(format!( - "Sync step 4/7: Validating chain state - range proofs: {}%", - r_percent + "Sync step 3/7: Preparing for validation (kernel history) - {}/{} - {}%", + h, ht, percent )) - } - SyncStatus::TxHashsetKernelsValidation { - kernels, - kernels_total, - } => { - let k_percent = if kernels_total > 0 { - (kernels * 100) / kernels_total - } else { - 0 - }; + } else if kernel_pos.is_some() && kernel_pos_total.is_some() { + let k = kernel_pos.unwrap(); + let kt = kernel_pos_total.unwrap(); + let percent = k * 100 / kt; Cow::Owned(format!( - "Sync step 5/7: Validating chain state - kernels: {}%", - k_percent + "Sync step 3/7: Preparing for validation (kernel position) - {}/{} - {}%", + k, kt, percent )) + } else { + Cow::Borrowed("Sync step 3/7: Preparing chain state for validation") } - SyncStatus::TxHashsetSave => { - Cow::Borrowed("Sync step 6/7: Finalizing chain state for state sync") - } - SyncStatus::TxHashsetDone => { - Cow::Borrowed("Sync step 6/7: Finalized chain state for state sync") - } - SyncStatus::BodySync { - current_height, - highest_height, - } => { - let percent = if highest_height == 0 { - 0 - } else { - current_height * 100 / highest_height - }; - Cow::Owned(format!("Sync step 7/7: Downloading blocks: {}%", percent)) - } - SyncStatus::Shutdown => Cow::Borrowed("Shutting down, closing connections"), } + SyncStatus::TxHashsetRangeProofsValidation { + rproofs, + rproofs_total, + } => { + let r_percent = if rproofs_total > 0 { + (rproofs * 100) / rproofs_total + } else { + 0 + }; + Cow::Owned(format!( + "Sync step 4/7: Validating chain state - range proofs: {}%", + r_percent + )) + } + SyncStatus::TxHashsetKernelsValidation { + kernels, + kernels_total, + } => { + let k_percent = if kernels_total > 0 { + (kernels * 100) / kernels_total + } else { + 0 + }; + Cow::Owned(format!( + "Sync step 5/7: Validating chain state - kernels: {}%", + k_percent + )) + } + SyncStatus::TxHashsetSave => { + Cow::Borrowed("Sync step 6/7: Finalizing chain state for state sync") + } + SyncStatus::TxHashsetDone => { + Cow::Borrowed("Sync step 6/7: Finalized chain state for state sync") + } + SyncStatus::BodySync { + current_height, + highest_height, + } => { + let percent = if highest_height == 0 { + 0 + } else { + current_height * 100 / highest_height + }; + Cow::Owned(format!("Sync step 7/7: Downloading blocks: {}%", percent)) + } + SyncStatus::Shutdown => Cow::Borrowed("Shutting down, closing connections"), } +} - /// Create basic status view - pub fn create() -> impl View { - let basic_status_view = ResizedView::with_full_screen( - LinearLayout::new(Orientation::Vertical) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Uptime: ")) - .child(TextView::new("0").with_name("basic_uptime")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Current Status: ")) - .child(TextView::new("Starting").with_name("basic_current_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Connected Peers: ")) - .child(TextView::new("0").with_name("connected_peers")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Disk Usage (GB): ")) - .child(TextView::new("0").with_name("disk_usage")), - ) - .child( - LinearLayout::new(Orientation::Horizontal).child(TextView::new( - "--------------------------------------------------------", - )), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Header Tip Hash: ")) - .child(TextView::new(" ").with_name("basic_header_tip_hash")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Header Chain Height: ")) - .child(TextView::new(" ").with_name("basic_header_chain_height")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Header Cumulative Difficulty: ")) - .child(TextView::new(" ").with_name("basic_header_total_difficulty")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Header Tip Timestamp: ")) - .child(TextView::new(" ").with_name("basic_header_timestamp")), - ) - .child( - LinearLayout::new(Orientation::Horizontal).child(TextView::new( - "--------------------------------------------------------", - )), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Chain Tip Hash: ")) - .child(TextView::new(" ").with_name("tip_hash")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Chain Height: ")) - .child(TextView::new(" ").with_name("chain_height")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Chain Cumulative Difficulty: ")) - .child(TextView::new(" ").with_name("basic_total_difficulty")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Chain Tip Timestamp: ")) - .child(TextView::new(" ").with_name("chain_timestamp")), - ) - .child( - LinearLayout::new(Orientation::Horizontal).child(TextView::new( - "--------------------------------------------------------", - )), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Transaction Pool Size: ")) - .child(TextView::new("0").with_name("tx_pool_size")) - .child(TextView::new(" (")) - .child(TextView::new("0").with_name("tx_pool_kernels")) - .child(TextView::new(")")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new("Stem Pool Size: ")) - .child(TextView::new("0").with_name("stem_pool_size")) - .child(TextView::new(" (")) - .child(TextView::new("0").with_name("stem_pool_kernels")) - .child(TextView::new(")")), - ) - .child( - LinearLayout::new(Orientation::Horizontal).child(TextView::new( - "--------------------------------------------------------", - )), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("basic_mining_config_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("basic_mining_status")), - ) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(TextView::new(" ").with_name("basic_network_info")), - ), //.child(logo_view) - ); - basic_status_view.with_name(VIEW_BASIC_STATUS) - } +fn line(label: &str, value: impl Into) -> Line<'static> { + Line::from(format!("{:<30}{}", label, value.into())) } -impl TUIStatusListener for TUIStatusView { - fn update(c: &mut Cursive, stats: &ServerStats) { - let basic_status = TUIStatusView::update_sync_status(stats.sync_status); +const SEPARATOR: &str = "--------------------------------------------------------------------"; + +/// Draw the basic status view +pub fn draw(f: &mut Frame, area: Rect, app: &App) { + let mut lines: Vec = Vec::new(); - c.call_on_name("basic_uptime", |t: &mut TextView| { - t.set_content(stats.uptime_format()); - }); - c.call_on_name("basic_current_status", |t: &mut TextView| { - t.set_content(basic_status); - }); - c.call_on_name("connected_peers", |t: &mut TextView| { - t.set_content(stats.peer_count.to_string()); - }); - c.call_on_name("disk_usage", |t: &mut TextView| { - t.set_content(stats.disk_usage_gb.clone()); - }); - c.call_on_name("tip_hash", |t: &mut TextView| { - t.set_content(stats.chain_stats.last_block_h.to_string() + "..."); - }); - c.call_on_name("chain_height", |t: &mut TextView| { - t.set_content(stats.chain_stats.height.to_string()); - }); - c.call_on_name("basic_total_difficulty", |t: &mut TextView| { - t.set_content(stats.chain_stats.total_difficulty.to_string()); - }); - c.call_on_name("chain_timestamp", |t: &mut TextView| { - t.set_content(stats.chain_stats.latest_timestamp.to_string()); - }); - c.call_on_name("basic_header_tip_hash", |t: &mut TextView| { - t.set_content(stats.header_stats.last_block_h.to_string() + "..."); - }); - c.call_on_name("basic_header_chain_height", |t: &mut TextView| { - t.set_content(stats.header_stats.height.to_string()); - }); - c.call_on_name("basic_header_total_difficulty", |t: &mut TextView| { - t.set_content(stats.header_stats.total_difficulty.to_string()); - }); - c.call_on_name("basic_header_timestamp", |t: &mut TextView| { - t.set_content(stats.header_stats.latest_timestamp.to_string()); - }); - if let Some(tx_stats) = &stats.tx_stats { - c.call_on_name("tx_pool_size", |t: &mut TextView| { - t.set_content(tx_stats.tx_pool_size.to_string()); - }); - c.call_on_name("stem_pool_size", |t: &mut TextView| { - t.set_content(tx_stats.stem_pool_size.to_string()); - }); - c.call_on_name("tx_pool_kernels", |t: &mut TextView| { - t.set_content(tx_stats.tx_pool_kernels.to_string()); - }); - c.call_on_name("stem_pool_kernels", |t: &mut TextView| { - t.set_content(tx_stats.stem_pool_kernels.to_string()); - }); + match &app.stats { + None => { + lines.push(line("Current Status:", "Starting")); + } + Some(stats) => { + let sync_status = update_sync_status(stats.sync_status); + lines.push(line("Uptime:", stats.uptime_format())); + lines.push(line("Current Status:", sync_status.to_string())); + lines.push(line("Connected Peers:", stats.peer_count.to_string())); + lines.push(line("Disk Usage (GB):", stats.disk_usage_gb.clone())); + lines.push(Line::from(SEPARATOR)); + lines.push(line( + "Header Tip Hash:", + format!("{}...", stats.header_stats.last_block_h), + )); + lines.push(line( + "Header Chain Height:", + stats.header_stats.height.to_string(), + )); + lines.push(line( + "Header Cumulative Difficulty:", + stats.header_stats.total_difficulty.to_string(), + )); + lines.push(line( + "Header Tip Timestamp:", + stats.header_stats.latest_timestamp.to_string(), + )); + lines.push(Line::from(SEPARATOR)); + lines.push(line( + "Chain Tip Hash:", + format!("{}...", stats.chain_stats.last_block_h), + )); + lines.push(line("Chain Height:", stats.chain_stats.height.to_string())); + lines.push(line( + "Chain Cumulative Difficulty:", + stats.chain_stats.total_difficulty.to_string(), + )); + lines.push(line( + "Chain Tip Timestamp:", + stats.chain_stats.latest_timestamp.to_string(), + )); + lines.push(Line::from(SEPARATOR)); + if let Some(tx_stats) = &stats.tx_stats { + lines.push(line( + "Transaction Pool Size:", + format!("{} ({})", tx_stats.tx_pool_size, tx_stats.tx_pool_kernels), + )); + lines.push(line( + "Stem Pool Size:", + format!( + "{} ({})", + tx_stats.stem_pool_size, tx_stats.stem_pool_kernels + ), + )); + } else { + lines.push(line("Transaction Pool Size:", "0 (0)")); + lines.push(line("Stem Pool Size:", "0 (0)")); + } + lines.push(Line::from(SEPARATOR)); + // These three lines are reserved for mining config/status/network info, + // matching the original view - never actually populated upstream. + lines.push(Line::from("")); + lines.push(Line::from("")); + lines.push(Line::from("")); } } + + let paragraph = Paragraph::new(lines); + f.render_widget(paragraph, area); } #[test] @@ -356,7 +257,7 @@ fn test_status_txhashset_kernels() { kernels: 201, kernels_total: 5000, }; - let basic_status = TUIStatusView::update_sync_status(status); + let basic_status = update_sync_status(status); assert!(basic_status.contains("4%"), "{}", basic_status); } @@ -366,6 +267,6 @@ fn test_status_txhashset_rproofs() { rproofs: 643, rproofs_total: 1000, }; - let basic_status = TUIStatusView::update_sync_status(status); + let basic_status = update_sync_status(status); assert!(basic_status.contains("64%"), "{}", basic_status); } diff --git a/src/bin/tui/types.rs b/src/bin/tui/types.rs index 03a6be2a73..7d3b97c041 100644 --- a/src/bin/tui/types.rs +++ b/src/bin/tui/types.rs @@ -15,18 +15,9 @@ //! Types specific to the UI module use crate::servers::ServerStats; -use cursive::Cursive; /// Main message struct to communicate between the UI and /// the main process pub enum UIMessage { UpdateStatus(ServerStats), } - -/// Trait for a UI element that receives status update messages -/// and updates itself - -pub trait TUIStatusListener { - /// Update according to status update contents - fn update(c: &mut Cursive, stats: &ServerStats); -} diff --git a/src/bin/tui/ui.rs b/src/bin/tui/ui.rs index 63165b78bc..de57482dc7 100644 --- a/src/bin/tui/ui.rs +++ b/src/bin/tui/ui.rs @@ -15,52 +15,59 @@ //! Basic TUI to better output the overall system status and status //! of various subsystems -use super::constants::MAIN_MENU; use crate::built_info; use crate::servers::Server; -use crate::tui::constants::{ROOT_STACK, VIEW_BASIC_STATUS, VIEW_MINING, VIEW_PEER_SYNC}; -use crate::tui::types::{TUIStatusListener, UIMessage}; +use crate::tui::app::{App, Dialog, DialogKind, Focus, MiningSubview, Tab}; +use crate::tui::types::UIMessage; use crate::tui::{logs, menu, mining, peers, status, version}; use chrono::prelude::Utc; -use cursive::direction::Orientation; -use cursive::theme::BaseColor::{Black, Blue, Cyan, White}; -use cursive::theme::Color::Dark; -use cursive::theme::PaletteColor::{ - Background, Highlight, HighlightInactive, Primary, Shadow, View, +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton, + MouseEventKind, }; -use cursive::theme::{BaseColor, BorderStyle, Color, Theme}; -use cursive::traits::{Nameable, Resizable}; -use cursive::utils::markup::StyledString; -use cursive::views::{ - CircularFocus, Dialog, LinearLayout, Panel, SelectView, StackView, TextView, ViewRef, +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use cursive::{CursiveRunnable, CursiveRunner}; use grin_core::global; use grin_servers::common::types::{Error, ServerInitStatus}; use grin_util::logger::LogEntry; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc}; -use std::{thread, time}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::{Frame, Terminal}; +use std::io::{self, Stdout}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +type Backend = CrosstermBackend; + +/// Redraw at least this often even without input or new data, so +/// time-based fields (peer "last seen" ages, etc.) stay fresh. +const MAX_REDRAW_INTERVAL: Duration = Duration::from_millis(250); + +/// How far PageUp/PageDown move a table selection +const TABLE_PAGE_SIZE: i64 = 10; + +fn install_panic_hook() { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + original_hook(panic_info); + })); +} pub struct UI { - cursive: CursiveRunner, + terminal: Terminal, + app: App, ui_rx: mpsc::Receiver, ui_tx: mpsc::Sender, controller_tx: mpsc::Sender, logs_rx: Option>, - show_dialog: Arc, -} - -fn modify_theme(theme: &mut Theme) { - theme.shadow = false; - theme.borders = BorderStyle::Simple; - theme.palette[Background] = Dark(Black); - theme.palette[Shadow] = Dark(Black); - theme.palette[View] = Dark(Black); - theme.palette[Primary] = Dark(White); - theme.palette[Highlight] = Dark(Cyan); - theme.palette[HighlightInactive] = Dark(Blue); - // also secondary, tertiary, TitlePrimary, TitleSecondary + needs_redraw: bool, + last_draw: Instant, } impl UI { @@ -68,115 +75,258 @@ impl UI { pub fn new( controller_tx: mpsc::Sender, logs_rx: Option>, - ) -> UI { + ) -> io::Result { let (ui_tx, ui_rx) = mpsc::channel::(); - let mut grin_ui = UI { - cursive: cursive::crossterm().into_runner(), + install_panic_hook(); + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let terminal = Terminal::new(CrosstermBackend::new(stdout))?; + + Ok(UI { + terminal, + app: App::new(), ui_tx, ui_rx, controller_tx, logs_rx, - show_dialog: Arc::new(AtomicBool::new(false)), - }; + needs_redraw: true, + last_draw: Instant::now(), + }) + } - // Create UI objects, etc - let status_view = status::TUIStatusView::create(); - let mining_view = mining::TUIMiningView::create(); - let peer_view = peers::TUIPeerView::create(); - let logs_view = logs::TUILogsView::create(); - let version_view = version::TUIVersionView::create(); - - let main_menu = menu::create(); - - let root_stack = StackView::new() - .layer(version_view) - .layer(mining_view) - .layer(peer_view) - .layer(logs_view) - .layer(status_view) - .with_name(ROOT_STACK) - .full_height(); - - let mut title_string = StyledString::new(); - title_string.append(StyledString::styled( - format!( - "Grin Version {} [{:?}]", - built_info::PKG_VERSION, - global::get_chain_type() - ), - Dark(BaseColor::Green), - )); - - let main_layer = LinearLayout::new(Orientation::Vertical) - .child(Panel::new(TextView::new(title_string).full_width())) - .child( - LinearLayout::new(Orientation::Horizontal) - .child(Panel::new(main_menu)) - .child(Panel::new(root_stack)), - ); - - //set theme - let mut theme = grin_ui.cursive.current_theme().clone(); - modify_theme(&mut theme); - grin_ui.cursive.set_theme(theme); - - grin_ui.cursive.add_fullscreen_layer(main_layer); - - // Configure a callback (shutdown, for the first test) - let controller_tx_clone = grin_ui.controller_tx.clone(); - let show_dialog_clone = grin_ui.show_dialog.clone(); - grin_ui.cursive.add_global_callback('q', move |c| { - if show_dialog_clone.load(Ordering::Relaxed) { - c.pop_layer(); + fn handle_key(&mut self, code: KeyCode) { + if let Some(dialog) = &self.app.dialog { + if dialog.kind == DialogKind::Error { + if matches!(code, KeyCode::Enter | KeyCode::Char('q') | KeyCode::Esc) { + self.app.should_quit = true; + } + return; } - let content = StyledString::styled("Shutting down...", Color::Light(BaseColor::Yellow)); - c.add_layer(CircularFocus::new(Dialog::around(TextView::new(content))).wrap_tab()); - let _ = controller_tx_clone.send(ControllerMessage::Shutdown); - }); + } + + match code { + KeyCode::Char('q') => { + self.app.dialog = Some(Dialog { + text: "Shutting down...".to_string(), + kind: DialogKind::Info, + }); + let _ = self.controller_tx.send(ControllerMessage::Shutdown); + } + KeyCode::Char('j') | KeyCode::Down => self.on_down(), + KeyCode::Char('k') | KeyCode::Up => self.on_up(), + KeyCode::PageDown => self.move_table_selection(TABLE_PAGE_SIZE), + KeyCode::PageUp => self.move_table_selection(-TABLE_PAGE_SIZE), + KeyCode::Home => self.move_table_selection(i64::MIN), + KeyCode::End => self.move_table_selection(i64::MAX), + KeyCode::Tab => self.app.select_menu_next(), + KeyCode::Enter => { + if self.app.focus == Focus::Menu { + self.app.focus = Focus::Content; + } + } + KeyCode::Esc => self.app.focus = Focus::Menu, + KeyCode::Char('w') if self.app.tab == Tab::Mining => { + self.app.mining_subview = MiningSubview::Workers + } + KeyCode::Char('d') if self.app.tab == Tab::Mining => { + self.app.mining_subview = MiningSubview::Difficulty + } + _ => {} + } + } + + fn on_down(&mut self) { + match self.app.focus { + Focus::Menu => self.app.select_menu_next(), + Focus::Content => self.move_table_selection(1), + } + } + + fn on_up(&mut self) { + match self.app.focus { + Focus::Menu => self.app.select_menu_prev(), + Focus::Content => self.move_table_selection(-1), + } + } + + fn move_table_selection(&mut self, delta: i64) { + let len = self.app.current_table_len(); + let state = match self.app.tab { + Tab::Peers => Some(&mut self.app.peers_table), + Tab::Mining => Some(match self.app.mining_subview { + MiningSubview::Workers => &mut self.app.mining_workers_table, + MiningSubview::Difficulty => &mut self.app.mining_diff_table, + }), + _ => None, + }; + if let Some(state) = state { + if len == 0 { + state.select(None); + return; + } + let current = state.selected().unwrap_or(0) as i64; + let next = current.saturating_add(delta).clamp(0, len as i64 - 1); + state.select(Some(next as usize)); + } + } - grin_ui.cursive.set_fps(3); - grin_ui + fn handle_mouse(&mut self, kind: MouseEventKind, column: u16, row: u16) { + if self.app.dialog.is_some() { + return; + } + match kind { + MouseEventKind::Down(MouseButton::Left) => { + let pos = Position { x: column, y: row }; + if self.app.menu_area.contains(pos) { + let idx = (row - self.app.menu_area.y) as usize; + if idx < Tab::ALL.len() { + self.app.tab = Tab::ALL[idx]; + self.app.focus = Focus::Menu; + } + } + } + MouseEventKind::ScrollDown => self.move_table_selection(1), + MouseEventKind::ScrollUp => self.move_table_selection(-1), + _ => {} + } } - /// Step the UI by calling into Cursive's step function, then - /// processing any UI messages + /// Step the UI: drain pending messages, handle one input event (if any), + /// then redraw if anything changed (or the periodic refresh is due). pub fn step(&mut self) -> bool { - if !self.cursive.is_running() { + if self.app.should_quit { return false; } if let Some(logs_rx) = &self.logs_rx { while let Some(message) = logs_rx.try_iter().next() { - logs::TUILogsView::update(&mut self.cursive, message); + self.app.push_log(message); + self.needs_redraw = true; } } - // Process any pending UI messages while let Some(message) = self.ui_rx.try_iter().next() { - let menu: ViewRef> = self.cursive.find_name(MAIN_MENU).unwrap(); - if let Some(selection) = menu.selection() { - match message { - UIMessage::UpdateStatus(update) => match *selection { - VIEW_BASIC_STATUS => { - status::TUIStatusView::update(&mut self.cursive, &update) - } - VIEW_MINING => mining::TUIMiningView::update(&mut self.cursive, &update), - VIEW_PEER_SYNC => peers::TUIPeerView::update(&mut self.cursive, &update), - _ => {} - }, + match message { + UIMessage::UpdateStatus(update) => self.app.stats = Some(update), + } + self.needs_redraw = true; + } + + if event::poll(Duration::from_millis(50)).unwrap_or(false) { + match event::read() { + Ok(Event::Key(key)) => { + if key.kind == KeyEventKind::Press { + self.handle_key(key.code); + self.needs_redraw = true; + } + } + Ok(Event::Mouse(mouse)) => { + self.handle_mouse(mouse.kind, mouse.column, mouse.row); + self.needs_redraw = true; } + Ok(Event::Resize(_, _)) => self.needs_redraw = true, + _ => {} } } - // Step the UI - self.cursive.step(); + if self.needs_redraw || self.last_draw.elapsed() >= MAX_REDRAW_INTERVAL { + let app = &mut self.app; + let _ = self.terminal.draw(|f| draw(f, app)); + self.needs_redraw = false; + self.last_draw = Instant::now(); + } true } - /// Stop the UI + /// Stop the UI and restore the terminal pub fn stop(&mut self) { - self.cursive.quit(); + self.app.should_quit = true; + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = self.terminal.show_cursor(); + } +} + +impl Drop for UI { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +fn draw_dialog(f: &mut Frame, area: Rect, dialog: &Dialog) { + let popup_area = centered_rect(60, 20, area); + let (color, title) = match dialog.kind { + DialogKind::Info => (Color::Green, ""), + DialogKind::Error => (Color::Red, "Error (press Enter or q to exit)"), + }; + let block = Block::default().borders(Borders::ALL).title(title); + let paragraph = Paragraph::new(dialog.text.clone()) + .style(Style::default().fg(color)) + .block(block) + .wrap(Wrap { trim: false }); + f.render_widget(Clear, popup_area); + f.render_widget(paragraph, popup_area); +} + +fn draw(f: &mut Frame, app: &mut App) { + let size = f.area(); + let outer = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(size); + + let title = format!( + "Grin Version {} [{:?}]", + built_info::PKG_VERSION, + global::get_chain_type() + ); + let title_widget = Paragraph::new(title) + .style(Style::default().fg(Color::Green)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title_widget, outer[0]); + + let body = Layout::horizontal([Constraint::Length(24), Constraint::Min(0)]).split(outer[1]); + + menu::draw(f, body[0], app); + + let content_block = Block::default().borders(Borders::ALL); + let content_area = content_block.inner(body[1]); + f.render_widget(content_block, body[1]); + + match app.tab { + Tab::Status => status::draw(f, content_area, app), + Tab::Peers => peers::draw(f, content_area, app), + Tab::Mining => mining::draw(f, content_area, app), + Tab::Logs => logs::draw(f, content_area, app), + Tab::Version => version::draw(f, content_area), + } + + if let Some(dialog) = &app.dialog { + draw_dialog(f, size, dialog); } } @@ -200,7 +350,7 @@ impl Controller { let (tx, rx) = mpsc::channel::(); Ok(Controller { rx, - ui: UI::new(tx, logs_rx), + ui: UI::new(tx, logs_rx).map_err(|e| e.to_string())?, serv_rx, server: None, }) @@ -211,30 +361,26 @@ impl Controller { if pop { self.pop_dialog(); } - let content = StyledString::styled(text, Color::Light(BaseColor::Green)); - self.ui - .cursive - .add_layer(CircularFocus::new(Dialog::around(TextView::new(content))).wrap_tab()); - self.ui.show_dialog.store(true, Ordering::Relaxed); + self.ui.app.dialog = Some(Dialog { + text: text.to_string(), + kind: DialogKind::Info, + }); + self.ui.needs_redraw = true; } /// Server initialization error. pub fn init_error(&mut self, e: Error) { self.pop_dialog(); - let content = StyledString::styled(format!("{:?}", e), Color::Light(BaseColor::Red)); - self.ui.cursive.add_layer( - CircularFocus::new(Dialog::around(TextView::new(content)).button("Exit", |s| { - s.quit(); - })) - .wrap_tab(), - ); - self.ui.show_dialog.store(true, Ordering::Relaxed); + self.ui.app.dialog = Some(Dialog { + text: format!("{:?}", e), + kind: DialogKind::Error, + }); + self.ui.needs_redraw = true; } fn pop_dialog(&mut self) { - if self.ui.show_dialog.swap(false, Ordering::Relaxed) { - self.ui.cursive.pop_layer(); - } + self.ui.app.dialog = None; + self.ui.needs_redraw = true; } /// Stop a fully initialized server, including one queued by the startup thread. @@ -262,7 +408,6 @@ impl Controller { let stat_update_interval = 1; let mut next_stat_update = Utc::now().timestamp() + stat_update_interval; - let delay = time::Duration::from_millis(50); let mut exit_code = 0; while self.ui.step() { if let Some(message) = self.rx.try_iter().next() { @@ -304,7 +449,6 @@ impl Controller { } } } - thread::sleep(delay); } self.stop_server(); exit_code diff --git a/src/bin/tui/version.rs b/src/bin/tui/version.rs index 61bd0e1434..61621d8147 100644 --- a/src/bin/tui/version.rs +++ b/src/bin/tui/version.rs @@ -14,27 +14,20 @@ //! Version and build info -use cursive::direction::Orientation; -use cursive::traits::Nameable; -use cursive::view::View; -use cursive::views::{LinearLayout, ResizedView, TextView}; - -use crate::tui::constants::VIEW_VERSION; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::Frame; use crate::info_strings; -pub struct TUIVersionView; - -impl TUIVersionView { - /// Create basic status view - pub fn create() -> Box { - let (basic_info, detailed_info) = info_strings(); - let basic_status_view = ResizedView::with_full_screen( - LinearLayout::new(Orientation::Vertical) - .child(TextView::new(basic_info)) - .child(TextView::new(" ")) - .child(TextView::new(detailed_info)), - ); - Box::new(basic_status_view.with_name(VIEW_VERSION)) - } +/// Draw basic version/build info +pub fn draw(f: &mut Frame, area: Rect) { + let (basic_info, detailed_info) = info_strings(); + let lines = vec![ + Line::from(basic_info), + Line::from(""), + Line::from(detailed_info), + ]; + f.render_widget(Paragraph::new(lines), area); } From 0b050a789e34be07dbfb03d5a55c0654dacf2530 Mon Sep 17 00:00:00 2001 From: iho Date: Fri, 10 Jul 2026 20:42:11 +0300 Subject: [PATCH 2/3] 2026 --- src/bin/tui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/tui/app.rs b/src/bin/tui/app.rs index cf924bc00b..a504300c08 100644 --- a/src/bin/tui/app.rs +++ b/src/bin/tui/app.rs @@ -1,4 +1,4 @@ -// Copyright 2024 The Grin Developers +// Copyright 2026 The Grin Developers // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From 0f62d9356aa6a700c68e469f982976a6d81b3605 Mon Sep 17 00:00:00 2001 From: iho Date: Mon, 20 Jul 2026 18:31:03 +0300 Subject: [PATCH 3/3] Regenerate Cargo.lock after rebase onto staging Removes the now-unused cursive/pancurses dependency tree and resolves the ratatui/crossterm graph against the current staging lockfile. --- Cargo.lock | 372 ++++++++++++++++++++++------------------------------- 1 file changed, 151 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df63b8c02f..cd3e69947c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,19 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if 1.0.4", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -39,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -274,6 +267,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "castaway" version = "0.2.4" @@ -336,7 +335,7 @@ dependencies = [ "ansi_term 0.12.1", "atty", "bitflags 1.3.2", - "strsim", + "strsim 0.8.0", "textwrap", "unicode-width 0.1.14", "vec_map", @@ -354,9 +353,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" dependencies = [ "castaway", "cfg-if 1.0.4", @@ -425,15 +424,6 @@ dependencies = [ "cc", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -505,71 +495,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "cursive" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "386d5a36020bb856e9a34ecb8a4e6c9bd6b0262d1857bae4db7bc7e2fdaa532e" -dependencies = [ - "ahash", - "cfg-if 1.0.4", - "crossbeam-channel", - "crossterm", - "cursive_core", - "lazy_static", - "libc", - "log", - "signal-hook", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "cursive-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7ac0eb0cede3dfdfebf4d5f22354e05a730b79c25fd03481fc69fcfba0a73e" -dependencies = [ - "proc-macro2 1.0.106", -] - -[[package]] -name = "cursive_core" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d40c54bcff5c30d198d0c089b3f1674e2b3b7b438147b798fe247c955e171c4f" -dependencies = [ - "ahash", - "compact_str", - "crossbeam-channel", - "cursive-macros", - "enum-map", - "enumset", - "jiff", - "lazy_static", - "log", - "num 0.4.3", - "parking_lot", - "serde_json", - "unicode-segmentation", - "unicode-width 0.2.2", - "xi-unicode", -] - -[[package]] -name = "cursive_table_view" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86a6cca21da26bb588502349d09b68574a3e55689ddeaeeb086af373e0ef6766" -dependencies = [ - "cursive_core", -] - [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -577,22 +507,22 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2 1.0.106", "quote 1.0.45", + "strsim 0.11.1", "syn 2.0.118", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote 1.0.45", @@ -714,31 +644,17 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6368dbd2c6685fb84fc6e6a4749917ddc98905793fd06341c7e11a2504f2724" dependencies = [ - "heck", + "heck 0.3.3", "proc-macro2 0.4.30", "quote 0.6.13", "syn 0.15.44", ] [[package]] -name = "enum-map" -version = "2.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" -dependencies = [ - "enum-map-derive", -] - -[[package]] -name = "enum-map-derive" -version = "0.17.0" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" -dependencies = [ - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.118", -] +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "enum_primitive" @@ -749,27 +665,6 @@ dependencies = [ "num-traits 0.1.43", ] -[[package]] -name = "enumset" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" -dependencies = [ - "enumset_derive", -] - -[[package]] -name = "enumset_derive" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" -dependencies = [ - "darling", - "proc-macro2 1.0.106", - "quote 1.0.45", - "syn 2.0.118", -] - [[package]] name = "env_logger" version = "0.7.1" @@ -837,6 +732,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1025,9 +926,8 @@ dependencies = [ "built", "chrono", "clap", + "crossterm", "ctrlc", - "cursive", - "cursive_table_view", "grin_api", "grin_chain", "grin_config", @@ -1038,6 +938,7 @@ dependencies = [ "grin_util", "humansize", "log", + "ratatui", "serde", "serde_derive", "serde_json", @@ -1133,7 +1034,7 @@ dependencies = [ "lazy_static", "log", "lru-cache", - "num 0.2.1", + "num", "num-bigint", "rand 0.6.5", "serde", @@ -1181,7 +1082,7 @@ dependencies = [ "grin_util", "log", "lru-cache", - "num 0.2.1", + "num", "rand 0.6.5", "serde", "serde_derive", @@ -1312,6 +1213,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1327,6 +1239,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "heed" version = "0.22.1" @@ -1662,55 +1580,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.28" +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", + "rustversion", ] [[package]] -name = "jiff-static" -version = "0.2.28" +name = "instability" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ + "darling", + "indoc", "proc-macro2 1.0.106", "quote 1.0.45", "syn 2.0.118", ] [[package]] -name = "jiff-tzdb" -version = "0.1.6" +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] [[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -1880,6 +1788,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -1964,23 +1881,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ "num-bigint", - "num-complex 0.2.4", - "num-integer", - "num-iter", - "num-rational 0.2.4", - "num-traits 0.2.19", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-complex 0.4.6", + "num-complex", "num-integer", "num-iter", - "num-rational 0.4.2", + "num-rational", "num-traits 0.2.19", ] @@ -2005,15 +1909,6 @@ dependencies = [ "num-traits 0.2.19", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits 0.2.19", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -2046,16 +1941,6 @@ dependencies = [ "num-traits 0.2.19", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-integer", - "num-traits 0.2.19", -] - [[package]] name = "num-traits" version = "0.1.43" @@ -2178,6 +2063,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.8.0" @@ -2251,21 +2142,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -2513,6 +2389,27 @@ dependencies = [ "rand_core 0.3.1", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rdrand" version = "0.4.0" @@ -2932,6 +2829,34 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2 1.0.106", + "quote 1.0.45", + "rustversion", + "syn 2.0.118", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3210,6 +3135,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -3218,9 +3154,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -3554,12 +3490,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "xi-unicode" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" - [[package]] name = "yaml-rust" version = "0.3.5"