pyblock/pybitblock/tui/app.py
GaltRanch 86aff5bad2 Fix TUI widget lifecycle: use single Static view for content swapping
Replace mount/remove_children pattern (async issues, duplicate IDs)
with a single persistent #section-view Static widget that gets its
content updated via .update(). All sections now render correctly:

- Dashboard: summary table + latest blocks (fetched async from API)
- Bitcoin/Lightning/Platforms/Settings: info panels
- M key: return to main menu

Also added fetch_latest_blocks() and fetch_hashrate() data workers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:15:44 -03:00

282 lines
9.4 KiB
Python

"""
PyBLOCK Textual TUI Application.
Launch with: python3 -m pybitblock.tui.app
Or from PyBlock.py with: --tui flag
"""
from textual.app import App, ComposeResult
from textual.widgets import Footer, Static, RichLog
from textual.containers import Vertical
from textual.binding import Binding
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from tui.widgets.status_bar import StatusBar
from tui.widgets.main_menu import MainMenu
from tui.workers.data_fetcher import (
fetch_block_height, fetch_btc_price, fetch_fees,
fetch_mempool_info, fetch_latest_blocks, fetch_hashrate,
)
CSS = """
Screen {
background: rgb(15, 15, 15);
}
#status-bar {
dock: top;
height: 1;
background: rgb(20, 20, 20);
}
#content {
height: 1fr;
padding: 1 2;
overflow-y: auto;
}
#fees-panel {
dock: right;
width: 28;
height: 100%;
padding: 1;
background: rgb(25, 25, 25);
border-left: solid rgb(50, 50, 50);
}
Footer {
background: rgb(30, 30, 30);
}
"""
class PyBlockApp(App):
"""PyBLOCK Bitcoin Dashboard TUI."""
TITLE = "PyBLOCK"
SUB_TITLE = "Bitcoin Dashboard"
CSS = CSS
BINDINGS = [
Binding("m", "show_menu", "Menu", show=True),
Binding("a", "select('pyblock')", "Dashboard", show=True),
Binding("b", "select('bitcoin')", "Bitcoin", show=True),
Binding("l", "select('lightning')", "Lightning", show=True),
Binding("p", "select('platforms')", "Platforms", show=True),
Binding("s", "select('settings')", "Settings", show=True),
Binding("q", "quit", "Quit", show=True),
Binding("ctrl+r", "refresh_data", "Refresh", show=True),
]
def __init__(self, mode="lite"):
super().__init__()
self.mode = mode
self._block = "---"
self._price = "---"
self._fees = {}
def compose(self) -> ComposeResult:
yield StatusBar(id="status-bar")
yield Vertical(
Static(id="section-view"),
id="content",
)
yield self._build_fees_panel()
yield Footer()
def _build_fees_panel(self):
return Static(
"[bold yellow]Fees[/bold yellow]\n"
"[dim]Loading...[/dim]",
id="fees-panel",
)
def on_mount(self):
self.query_one(StatusBar).mode = self.mode
self.action_show_menu()
self.set_interval(30, self._do_refresh)
self._do_refresh()
def _do_refresh(self):
self.run_worker(self._refresh_data_worker, thread=True)
def _refresh_data_worker(self):
block = fetch_block_height()
price = fetch_btc_price()
fees = fetch_fees()
self.call_from_thread(self._update_status, block, price, fees)
def _update_status(self, block, price, fees):
self._block = block
self._price = price
self._fees = fees
status = self.query_one(StatusBar)
status.block_height = block
status.btc_price = price
fees_panel = self.query_one("#fees-panel", Static)
fees_panel.update(
f"[bold yellow]Fees (sat/vB)[/bold yellow]\n\n"
f"[green]Fast:[/green] {fees.get('fastestFee', '?')}\n"
f"[yellow]Medium:[/yellow] {fees.get('halfHourFee', '?')}\n"
f"[dim]Slow:[/dim] {fees.get('hourFee', '?')}\n"
)
def _set_content(self, renderable):
"""Replace the content area with new renderable."""
try:
view = self.query_one("#section-view", Static)
view.update(renderable)
except Exception:
pass
# --- Actions ---
def action_show_menu(self):
menu = MainMenu(mode=self.mode)
# Build the menu renderable
self._set_content(menu._build_menu())
def action_select(self, section):
if section == "pyblock":
self._load_dashboard()
elif section == "bitcoin":
self._load_bitcoin()
elif section == "lightning":
self._load_lightning()
elif section == "platforms":
self._load_platforms()
elif section == "settings":
self._load_settings()
def _load_dashboard(self):
"""Show dashboard with block, price, mempool summary."""
self.notify("Loading dashboard...", timeout=1)
self.run_worker(self._fetch_dashboard, thread=True)
def _fetch_dashboard(self):
mempool = fetch_mempool_info()
blocks = fetch_latest_blocks()
hashrate = fetch_hashrate()
self.call_from_thread(self._render_dashboard_sync, mempool, blocks, hashrate)
def _render_dashboard_sync(self, mempool, blocks, hashrate):
"""Build dashboard as a Rich Group and update the section view."""
from rich.console import Group
summary = Table(show_header=False, box=None, padding=(0, 2))
summary.add_column("Key", style="bold yellow", width=18)
summary.add_column("Value", style="bold white")
summary.add_row("Block Height", str(self._block))
summary.add_row("BTC Price", f"${self._price}")
summary.add_row("Hashrate", f"{hashrate['hashrate_eh']} EH/s")
summary.add_row("Difficulty", hashrate["difficulty"])
summary.add_row("Mempool Txs", f"{mempool.get('count', '?'):,}" if isinstance(mempool.get('count'), int) else str(mempool.get('count', '?')))
summary.add_row("Mempool Size", f"{mempool.get('vsize', 0) / 1_000_000:.1f} MvB" if isinstance(mempool.get('vsize'), (int, float)) else "?")
blocks_table = Table(title="Latest Blocks", expand=False, padding=(0, 1))
blocks_table.add_column("Height", style="bold green", width=10)
blocks_table.add_column("Txs", style="white", width=8, justify="right")
blocks_table.add_column("Size (MB)", style="cyan", width=10, justify="right")
blocks_table.add_column("Pool", style="yellow", width=16)
for b in blocks:
blocks_table.add_row(str(b["height"]), str(b["tx_count"]), str(b["size"]), b["pool"])
dashboard = Group(
Panel(summary, title="[bold red]PyBLOCK Dashboard[/bold red]", expand=False, padding=(1, 2)),
"",
Panel(blocks_table, expand=False, padding=(0, 1)),
)
self._set_content(dashboard)
def _load_bitcoin(self):
"""Show Bitcoin info panel."""
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Key", width=4, justify="right")
table.add_column("Label")
items = [
("A", "Blockchain Info", "bold rgb(255,102,0)"),
("C", "Mempool Monitor", "bold rgb(255,102,0)"),
("D", "Latest Blocks", "bold rgb(255,102,0)"),
("E", "Fee Estimates", "bold rgb(255,102,0)"),
("H", "Hashrate & Difficulty", "bold rgb(255,102,0)"),
]
for key, label, style in items:
table.add_row(Text(f"{key}.", style=style), Text(label, style="white"))
panel = Panel(table, title="[bold rgb(255,102,0)]Bitcoin[/bold rgb(255,102,0)]",
subtitle="[dim]Press M for main menu[/dim]", expand=False, padding=(1, 2))
self._set_content(panel)
self.notify("Bitcoin section - submenu navigation coming soon", timeout=2)
def _load_lightning(self):
"""Show Lightning info panel."""
panel = Panel(
"[bold yellow]Lightning Network[/bold yellow]\n\n"
"Connect your Lightning node to access:\n\n"
" [yellow]1.[/yellow] Channel Management\n"
" [yellow]2.[/yellow] Create/Pay Invoices\n"
" [yellow]3.[/yellow] Keysend Payments\n"
" [yellow]4.[/yellow] Node Info & Peers\n"
" [yellow]5.[/yellow] Rebalance Channels\n\n"
f"[dim]Mode: {self.mode} | Press M for main menu[/dim]",
title="[bold yellow]Lightning[/bold yellow]",
expand=False,
padding=(1, 2),
)
self._set_content(panel)
def _load_platforms(self):
"""Show Platforms panel."""
panel = Panel(
"[bold green]Platforms & APIs[/bold green]\n\n"
" [green]1.[/green] LNBits\n"
" [green]2.[/green] OpenNode\n"
" [green]3.[/green] TallyCoin\n"
" [green]4.[/green] CoinGecko Price\n"
" [green]5.[/green] Weather (wttr.in)\n"
" [green]6.[/green] Rate.sx Charts\n\n"
"[dim]Press M for main menu[/dim]",
title="[bold green]Platforms[/bold green]",
expand=False,
padding=(1, 2),
)
self._set_content(panel)
def _load_settings(self):
"""Show Settings panel."""
panel = Panel(
"[bold blue]Settings[/bold blue]\n\n"
f" [blue]Mode:[/blue] {self.mode}\n"
f" [blue]Block:[/blue] {self._block}\n"
f" [blue]Refresh:[/blue] 30s auto\n\n"
" [dim]Logo colors, fonts, and node\n"
" configuration available in\n"
" classic mode (without --tui)[/dim]\n\n"
"[dim]Press M for main menu[/dim]",
title="[bold blue]Settings[/bold blue]",
expand=False,
padding=(1, 2),
)
self._set_content(panel)
def action_refresh_data(self):
self._do_refresh()
self.notify("Refreshing data...", timeout=1)
def action_quit(self):
self.exit()
def run(mode="lite"):
"""Run the PyBLOCK TUI application."""
app = PyBlockApp(mode=mode)
app.run()
if __name__ == "__main__":
run()