Merge pull request #729 from GaltRanch/frontend/ux-improvements

This commit is contained in:
Satoshi Nakamoto 2026-04-01 20:48:49 +02:00 committed by GitHub
commit cb2f4bee16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 299 additions and 62 deletions

View file

@ -2,6 +2,7 @@
#Tester: __B__T__C__
#𝕪𝔹𝕃𝕆𝕂 𝕚𝕥𝕤 𝕒 𝔹𝕚𝕥𝕔𝕠𝕚𝕟 𝔻𝕒𝕤𝕙𝕓𝕠𝕒𝕣𝕕 𝕨𝕚𝕥𝕙 𝕪𝕡𝕙𝕖𝕣𝕡𝕦𝕟𝕜 𝕒𝕖𝕤𝕥𝕙𝕖𝕥𝕚𝕔.
import codecs
import os
import os.path
import time as t
@ -887,6 +888,8 @@ def artist(): # here we convert the result of the command 'getblockcount' on a r
clear()
close()
design()
except KeyboardInterrupt:
break
except Exception as e:
logger.debug("Loop interrupted: %s", e)
break
@ -1824,6 +1827,20 @@ def MainMenu(mode): #Unified Main Menu - mode: "local", "onchain_only", or "remo
sysinfo()
pathexec()
# Validate bitcoincli path before attempting to use it
if mode in ("local", "onchain_only") and not path.get('bitcoincli'):
show_error("Bitcoin CLI path not configured. Redirecting to Lite Mode.")
t.sleep(2)
from SPV.spvblock import MainMenuCROPPED as _lite_menu
_lite_menu()
return
if mode == "remote" and not lndconnectload.get('tls'):
show_error("Remote node not configured. Redirecting to Lite Mode.")
t.sleep(2)
from SPV.spvblock import MainMenuCROPPED as _lite_menu
_lite_menu()
return
# Fetch BTC price for status bar
try:
_price_r = requests.get("https://mempool.space/api/v1/prices", timeout=3)
@ -5137,7 +5154,8 @@ def menuSelection():
path = pathv # Copy the variable pathv to 'path'
MainMenuLOCAL()
elif chln == "C":
MainMenuCROPPED()
from SPV.spvblock import MainMenuCROPPED as _lite_menu
_lite_menu()
else:
if os.path.isfile('config/blndconnect.conf'):
chln['offchain'] = "offchain"
@ -7405,17 +7423,9 @@ def main():
set_terminal_background()
menuSelection()
except KeyboardInterrupt:
print("\n")
sys.exit(0)
except PermissionError as e:
if str(e).endswith("''"):
show_error("Bitcoin CLI path is not configured. Please set it up.")
logger.error("Empty bitcoincli path in bclock.conf")
introINIT()
else:
logger.error("Fatal error: %s", e)
sys.exit(101)
continue # Ctrl+C returns to main menu
except Exception as e:
show_error(str(e))
logger.error("Fatal error: %s", e)
sys.exit(101)
@ -7427,10 +7437,16 @@ if __name__ == "__main__":
if cfg.has_config('intro.conf'):
with open("config/intro.conf", "r") as f:
init_data = json.load(f)
if init_data.get("fullbtclnd"):
mode = "local"
elif init_data.get("fullbtc"):
mode = "onchain_only"
if isinstance(init_data, str):
if init_data == "A":
mode = "local"
elif init_data == "B":
mode = "onchain_only"
elif isinstance(init_data, dict):
if init_data.get("fullbtclnd"):
mode = "local"
elif init_data.get("fullbtc"):
mode = "onchain_only"
run_tui(mode=mode)
else:
main()

View file

@ -37,7 +37,7 @@ PYBLOCK_THEME = Theme({
"pyblock.block": "bold white",
})
console = Console(theme=PYBLOCK_THEME)
console = Console(theme=PYBLOCK_THEME, highlight=False, color_system="truecolor")
def rich_status_bar(mode="", block_height="", btc_price="", extra=""):
@ -80,68 +80,47 @@ def rich_status_bar(mode="", block_height="", btc_price="", extra=""):
combined.append_text(separator)
combined.append_text(part)
console.print(Panel(combined, style="pyblock.dim", expand=False, padding=(0, 2)))
console.print(Panel(combined, expand=False, style="on default", border_style="dim", padding=(0, 2)))
def rich_sysinfo(cpu_percent, mem_percent):
"""Render CPU and Memory as a compact Rich panel."""
"""Render CPU and Memory with colored bars in a panel."""
cpu_color = "green" if cpu_percent < 70 else ("yellow" if cpu_percent < 90 else "red")
mem_color = "green" if mem_percent < 70 else ("yellow" if mem_percent < 90 else "red")
cpu_bar = _make_bar(cpu_percent, cpu_color)
mem_bar = _make_bar(mem_percent, mem_color)
table = Table(show_header=False, box=None, padding=(0, 1))
table.add_column(width=10)
table.add_column(width=22)
table.add_column(width=5, justify="right")
table.add_row(
Text("CPU", style="italic yellow"),
Text.from_markup(cpu_bar),
Text(f"{cpu_percent}%", style=f"bold {cpu_color}"),
text = Text.from_markup(
f"[italic yellow]CPU[/] {cpu_bar} [bold {cpu_color}]{cpu_percent}%[/]\n"
f"[italic yellow]Memory[/] {mem_bar} [bold {mem_color}]{mem_percent}%[/]"
)
table.add_row(
Text("Memory", style="italic yellow"),
Text.from_markup(mem_bar),
Text(f"{mem_percent}%", style=f"bold {mem_color}"),
)
console.print(table)
console.print(Panel(text, expand=False, style="on default", border_style="dim"))
def _make_bar(percent, color):
"""Create a simple text-based progress bar."""
filled = int(percent / 5)
empty = 20 - filled
return f"[{color}]{'' * filled}[/{color}][dim]{'' * empty}[/dim]"
return f"[{color}]{'' * filled}[/{color}][rgb(60,60,60)]{'' * empty}[/rgb(60,60,60)]"
def rich_menu(title, items, footer_text=""):
"""Render a styled menu table.
"""Render a styled menu in a panel.
Args:
title: Menu section title
items: List of (key, label, style) tuples
footer_text: Optional text below the menu
"""
table = Table(
show_header=False,
box=None,
padding=(0, 1),
pad_edge=False,
)
table.add_column("Key", width=6, justify="right")
table.add_column("Label")
lines = []
for key, label, style in items:
table.add_row(
Text(f"{key}.", style=f"bold {style}"),
Text(label, style="white"),
)
console.print()
console.print(table)
lines.append(f"[bold {style}]{key}.[/] {label}")
if footer_text:
console.print(f" [pyblock.dim]{footer_text}[/pyblock.dim]")
lines.append(f"\n[dim]{footer_text}[/dim]")
content = Text.from_markup("\n".join(lines))
console.print(Panel(content, expand=False, style="on default", border_style="dim", padding=(1, 2)))
console.print()
@ -191,7 +170,7 @@ def rich_header(node_type, block_height, version, alias=None):
info.append("Version: ", style="bold white")
info.append(f"{version}", style="dim")
console.print(Panel(info, style="pyblock.dim", expand=False, padding=(0, 2)))
console.print(Panel(info, expand=False, style="on default", border_style="dim", padding=(0, 2)))
def rich_loading(label="Loading"):

View file

@ -6,14 +6,19 @@ Or from PyBlock.py with: --tui flag
"""
from textual.app import App, ComposeResult
from textual.widgets import Footer, Static
from textual.widgets import Footer, Static, RichLog
from textual.containers import Vertical
from textual.timer import Timer
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.screens.main_menu import MainMenuScreen
from tui.workers.data_fetcher import fetch_block_height, fetch_btc_price, fetch_fees
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 = """
@ -30,6 +35,7 @@ Screen {
#content {
height: 1fr;
padding: 1 2;
overflow-y: auto;
}
#fees-panel {
@ -55,19 +61,27 @@ class PyBlockApp(App):
CSS = CSS
BINDINGS = [
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
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._update_timer = None
self._block = "---"
self._price = "---"
self._fees = {}
def compose(self) -> ComposeResult:
yield StatusBar(id="status-bar")
yield Vertical(
MainMenuScreen(mode=self.mode),
Static(id="section-view"),
id="content",
)
yield self._build_fees_panel()
@ -82,7 +96,8 @@ class PyBlockApp(App):
def on_mount(self):
self.query_one(StatusBar).mode = self.mode
self._update_timer = self.set_interval(30, self._do_refresh)
self.action_show_menu()
self.set_interval(30, self._do_refresh)
self._do_refresh()
def _do_refresh(self):
@ -92,9 +107,13 @@ class PyBlockApp(App):
block = fetch_block_height()
price = fetch_btc_price()
fees = fetch_fees()
self.call_from_thread(self._update_ui, block, price, 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
def _update_ui(self, block, price, fees):
status = self.query_one(StatusBar)
status.block_height = block
status.btc_price = price
@ -107,6 +126,144 @@ class PyBlockApp(App):
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)

View file

@ -0,0 +1,54 @@
"""Main menu widget for PyBLOCK TUI."""
from textual.widgets import Static
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
class MainMenu(Static):
"""The primary navigation menu rendered as a widget."""
def __init__(self, mode="lite", **kwargs):
super().__init__(**kwargs)
self.mode = mode
def on_mount(self):
self.update(self._build_menu())
def _build_menu(self):
table = Table(
show_header=False,
box=None,
padding=(0, 2),
expand=False,
)
table.add_column("Key", width=4, justify="right")
table.add_column("Label", width=30)
items = [
("A", "PyBLOCK Dashboard", "bold red"),
("B", "Bitcoin", "bold rgb(255,102,0)"),
]
if self.mode != "onchain_only":
items.append(("L", "Lightning Network", "bold yellow"))
items.extend([
("P", "Platforms & APIs", "bold rgb(0,200,0)"),
("S", "Settings", "bold blue"),
("X", "Donate", "bold white"),
("Q", "Exit", "bold rgb(128,0,255)"),
])
for key, label, style in items:
table.add_row(
Text(f"{key}.", style=style),
Text(label, style="white"),
)
return Panel(
table,
title="[bold red]PyBLOCK[/bold red]",
subtitle="[dim]Navigate with keyboard[/dim]",
expand=False,
padding=(1, 2),
)

View file

@ -43,3 +43,34 @@ def fetch_mempool_info():
}
except Exception:
return {"count": "?", "vsize": "?", "total_fee": "?"}
def fetch_latest_blocks():
"""Fetch latest 5 blocks from mempool.space."""
try:
r = requests.get("https://mempool.space/api/v1/blocks", timeout=5)
blocks = r.json()[:5]
return [
{
"height": b.get("height", "?"),
"tx_count": b.get("tx_count", "?"),
"size": round(b.get("size", 0) / 1_000_000, 2),
"pool": b.get("extras", {}).get("pool", {}).get("name", "Unknown"),
}
for b in blocks
]
except Exception:
return []
def fetch_hashrate():
"""Fetch network hashrate info."""
try:
r = requests.get("https://mempool.space/api/v1/mining/hashrate/3d", timeout=5)
data = r.json()
current = data.get("currentHashrate", 0)
difficulty = data.get("currentDifficulty", 0)
eh = current / 1e18
return {"hashrate_eh": f"{eh:.1f}", "difficulty": f"{difficulty:.2e}"}
except Exception:
return {"hashrate_eh": "?", "difficulty": "?"}