mirror of
https://github.com/curly60e/pyblock.git
synced 2026-08-13 12:33:15 +02:00
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>
282 lines
9.4 KiB
Python
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()
|