diff --git a/pybitblock/PyBlock.py b/pybitblock/PyBlock.py index d9bd5d3..4129852 100644 --- a/pybitblock/PyBlock.py +++ b/pybitblock/PyBlock.py @@ -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() diff --git a/pybitblock/shared/rich_ui.py b/pybitblock/shared/rich_ui.py index 696183c..b10fc8c 100644 --- a/pybitblock/shared/rich_ui.py +++ b/pybitblock/shared/rich_ui.py @@ -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"): diff --git a/pybitblock/tui/app.py b/pybitblock/tui/app.py index ba90bcf..6c58a45 100644 --- a/pybitblock/tui/app.py +++ b/pybitblock/tui/app.py @@ -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) diff --git a/pybitblock/tui/widgets/main_menu.py b/pybitblock/tui/widgets/main_menu.py new file mode 100644 index 0000000..948be1e --- /dev/null +++ b/pybitblock/tui/widgets/main_menu.py @@ -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), + ) diff --git a/pybitblock/tui/workers/data_fetcher.py b/pybitblock/tui/workers/data_fetcher.py index dca1f98..f653d81 100644 --- a/pybitblock/tui/workers/data_fetcher.py +++ b/pybitblock/tui/workers/data_fetcher.py @@ -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": "?"}