Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 151 additions & 221 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 2 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { version = "0.29", features = ["unstable-rendered-line-info"] }
crossterm = "0.28"
humansize = "1.1.0"
serde = "1"
serde_derive = "1"
Expand All @@ -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"]}

Expand Down
152 changes: 152 additions & 0 deletions src/bin/tui/app.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// 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.
// 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<ServerStats>,
pub logs: VecDeque<LogEntry>,
pub peers_table: TableState,
pub mining_workers_table: TableState,
pub mining_diff_table: TableState,
pub dialog: Option<Dialog>,
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];
}
}
66 changes: 0 additions & 66 deletions src/bin/tui/constants.rs

This file was deleted.

126 changes: 49 additions & 77 deletions src/bin/tui/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,93 +12,65 @@
// 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, Wrap};
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<LogEntry>,
}

impl LogBufferView {
fn new(size: usize) -> Self {
let mut buffer = VecDeque::new();
buffer.resize(
size,
LogEntry {
log: String::new(),
level: Level::Info,
},
);

LogBufferView { buffer }
}

fn update(&mut self, entry: LogEntry) {
self.buffer.push_front(entry);
self.buffer.pop_back();
/// Draw the logs view, bottom-anchoring the newest log lines.
///
/// Uses ratatui's wrapping so display width and whitespace match the terminal.
/// When content is shorter than the pane, it is shifted down so the newest
/// lines still sit on the bottom edge (the old cursive view behaved this way).
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
if area.width == 0 || area.height == 0 {
return;
}

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),
),
// logs ring buffer is newest-first; reverse so oldest is at the top.
let mut lines: Vec<Line> = Vec::new();
for entry in app.logs.iter().rev() {
for row in entry.log.trim_end_matches('\n').split('\n') {
lines.push(Line::from(Span::styled(
row.to_string(),
color(entry.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<Row> = 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;
}
}
});
}
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
let total = paragraph.line_count(area.width);
let height = area.height as usize;

if total > height {
// Scroll so the newest (wrapped) lines fill the pane.
let scroll = (total - height) as u16;
f.render_widget(paragraph.scroll((scroll, 0)), area);
} else {
// Bottom-align short content by rendering into a sub-area at the bottom.
let y_offset = (height - total) as u16;
let content_area = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
height: total as u16,
};
f.render_widget(paragraph, content_area);
}
}
Loading
Loading