Rebuild TUI as 4-tab layout with 10 new widgets

Replace sidebar + 5-screen layout with horizontal tab bar (F1-F4)
and persistent StatusStrip. Consolidate Position + Scan screens into
Signal (F3) with Monitor/Sweep/Sky Map sub-modes via ModeBar.

Screens:
  - F1 Dashboard: system health, tracking panel, quick actions, presets
  - F2 Control: motor tuning, compass rose, preset management
  - F3 Signal: RSSI monitor, 1D sweep (SweepPlot), 2D sky map (heatmap)
  - F4 System: NVS editor with regex filter, EEPROM, firmware info
  - F5 Console: push/pop overlay (no longer a tab)

New widgets: StatusStrip, ModeBar, SweepPlot, QuickActions, PresetList,
ReceiverInfo, MotorTuning, NvsFilter, SystemHealth, TrackingPanel.

Removed: PositionScreen, ScanScreen, DeviceStatusBar (functionality
absorbed into new screens and StatusStrip).

App-level position poll feeds StatusStrip and active screen at ~2 Hz.
Fix shared threading.Event across instances (class-level mutable default).
This commit is contained in:
Ryan Malloy 2026-02-14 18:04:50 -07:00
parent e7e71c47d7
commit 145763fcfb
23 changed files with 2796 additions and 865 deletions

View file

@ -1,33 +1,35 @@
"""Birdcage TUI — main application shell.
ContentSwitcher-based layout with sidebar navigation (F1-F5),
device status bar, and five swappable screen panels.
Horizontal tab bar (F1-F4) with persistent StatusStrip, ContentSwitcher
for four task-oriented screens, and F5 console overlay. App-level position
polling feeds the StatusStrip regardless of which tab is active.
"""
import argparse
import contextlib
import logging
import threading
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, ContentSwitcher, Footer, Header, Static
from textual.containers import Horizontal
from textual.widgets import Button, ContentSwitcher, Footer, Header
from birdcage_tui.screens.console import ConsoleScreen
from birdcage_tui.screens.position import PositionScreen
from birdcage_tui.screens.scan import ScanScreen
from birdcage_tui.screens.console import ConsoleOverlay
from birdcage_tui.screens.control import ControlScreen
from birdcage_tui.screens.dashboard import DashboardScreen
from birdcage_tui.screens.signal import SignalScreen
from birdcage_tui.screens.system import SystemScreen
from birdcage_tui.widgets.device_status_bar import DeviceStatusBar
from birdcage_tui.widgets.status_strip import StatusStrip
log = logging.getLogger(__name__)
MODES: dict[str, tuple[str, type]] = {
"position": ("F1 Position", PositionScreen),
"signal": ("F2 Signal", SignalScreen),
"scan": ("F3 Scan", ScanScreen),
TABS: dict[str, tuple[str, type]] = {
"dashboard": ("F1 Dashboard", DashboardScreen),
"control": ("F2 Control", ControlScreen),
"signal": ("F3 Signal", SignalScreen),
"system": ("F4 System", SystemScreen),
"console": ("F5 Console", ConsoleScreen),
}
@ -38,11 +40,11 @@ class BirdcageApp(App):
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "switch_mode('position')", "Position"),
Binding("f2", "switch_mode('signal')", "Signal"),
Binding("f3", "switch_mode('scan')", "Scan"),
Binding("f4", "switch_mode('system')", "System"),
Binding("f5", "switch_mode('console')", "Console"),
Binding("f1", "switch_tab('dashboard')", "Dashboard"),
Binding("f2", "switch_tab('control')", "Control"),
Binding("f3", "switch_tab('signal')", "Signal"),
Binding("f4", "switch_tab('system')", "System"),
Binding("f5", "toggle_console", "Console"),
Binding("q", "quit", "Quit"),
Binding("d", "toggle_dark", "Dark"),
]
@ -55,6 +57,13 @@ class BirdcageApp(App):
device: object = None
shutdown_event: threading.Event = threading.Event()
# App-level position cache (updated by background poll).
_current_az: float = 0.0
_current_el: float = 0.0
_prev_az: float = 0.0
_prev_el: float = 0.0
_console_visible: bool = False
@property
def SUB_TITLE(self) -> str: # noqa: N802
if self.demo_mode:
@ -63,22 +72,25 @@ class BirdcageApp(App):
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="main-area"):
with Vertical(id="sidebar"):
yield Static("\U0001f6f0\ufe0f Birdcage", classes="sidebar-title")
yield Static("Carryout G2", classes="sidebar-subtitle")
for mode_key, (label, _) in MODES.items():
yield Button(label, id=f"btn-{mode_key}", classes="sidebar-btn")
yield DeviceStatusBar(id="device-status")
with ContentSwitcher(id="content-area", initial="position"):
for mode_key, (_, screen_cls) in MODES.items():
yield screen_cls(id=mode_key)
yield StatusStrip(id="status-strip")
with Horizontal(id="tab-bar"):
for tab_key, (label, _) in TABS.items():
yield Button(label, id=f"tab-{tab_key}", classes="tab-btn")
with ContentSwitcher(id="content-area", initial="dashboard"):
for tab_key, (_, screen_cls) in TABS.items():
yield screen_cls(id=tab_key)
yield Footer()
def on_mount(self) -> None:
self.query_one("#btn-position").add_class("active")
# Fresh event per instance — class-level default is shared across instances.
self.shutdown_event = threading.Event()
self.query_one("#tab-dashboard").add_class("active")
self._setup_device()
# ------------------------------------------------------------------
# Device lifecycle
# ------------------------------------------------------------------
def _setup_device(self) -> None:
"""Create device (demo or real) and hand it to each screen."""
if self.demo_mode:
@ -98,6 +110,9 @@ class BirdcageApp(App):
self.run_worker(self._initialize_device, thread=True)
self._distribute_device()
self._update_status_strip_connection()
self._install_console()
self._start_position_poll()
async def _initialize_device(self) -> None:
"""Run device init in a worker thread (blocks on serial I/O)."""
@ -109,44 +124,176 @@ class BirdcageApp(App):
def _distribute_device(self) -> None:
"""Pass the device reference to every screen that wants it."""
for mode_key in MODES:
screen = self.query_one(f"#{mode_key}")
for tab_key in TABS:
screen = self.query_one(f"#{tab_key}")
if hasattr(screen, "set_device"):
screen.set_device(self.device)
status_bar = self.query_one("#device-status", DeviceStatusBar)
if hasattr(status_bar, "set_device"):
status_bar.set_device(self.device)
def _update_status_strip_connection(self) -> None:
"""Set the status strip's connection info from current device."""
strip = self.query_one("#status-strip", StatusStrip)
is_demo = type(self.device).__name__ == "DemoDevice" if self.device else False
strip.demo = is_demo or self.demo_mode
strip.connected = (
self.device is not None
and getattr(self.device, "is_connected", False)
and not strip.demo
)
strip.port = self.serial_port
def action_switch_mode(self, mode: str) -> None:
"""Switch the content area to *mode* and update sidebar highlight."""
def _install_console(self) -> None:
"""Pre-install the console overlay so it persists across open/close."""
self.install_screen(ConsoleOverlay(), name="console-overlay")
# ------------------------------------------------------------------
# App-level position poll
# ------------------------------------------------------------------
@work(thread=True, exclusive=True, group="app-position-poll")
def _start_position_poll(self) -> None:
"""Poll device at ~2 Hz for position, update StatusStrip globally."""
shutdown = self.shutdown_event
while not shutdown.is_set():
if self.device is None:
shutdown.wait(0.5)
continue
try:
pos = self.device.get_position()
az = pos["azimuth"]
el = pos["elevation"]
self._current_az = az
self._current_el = el
self.call_from_thread(self._update_position, az, el)
except Exception:
log.debug("App position poll failed", exc_info=True)
shutdown.wait(0.5)
def _update_position(self, az: float, el: float) -> None:
"""Push position to StatusStrip and active screen."""
strip = self.query_one("#status-strip", StatusStrip)
strip.azimuth = az
strip.elevation = el
# Detect movement from position delta
delta = abs(az - self._prev_az) + abs(el - self._prev_el)
if delta > 0.05:
strip.motor_state = "MOVING"
else:
strip.motor_state = "IDLE"
self._prev_az = az
self._prev_el = el
# Notify active screen
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = mode
if switcher.current:
try:
active = self.query_one(f"#{switcher.current}")
if hasattr(active, "on_position_update"):
active.on_position_update(az, el)
except Exception:
pass
for btn in self.query(".sidebar-btn"):
# ------------------------------------------------------------------
# Tab switching
# ------------------------------------------------------------------
def action_switch_tab(self, tab: str) -> None:
"""Switch the content area to *tab* and update tab bar highlight."""
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = tab
for btn in self.query(".tab-btn"):
btn.remove_class("active")
self.query_one(f"#btn-{mode}").add_class("active")
self.query_one(f"#tab-{tab}").add_class("active")
screen = self.query_one(f"#{mode}")
screen = self.query_one(f"#{tab}")
if hasattr(screen, "on_show"):
screen.on_show()
# ------------------------------------------------------------------
# Console overlay
# ------------------------------------------------------------------
def action_toggle_console(self) -> None:
"""Push or pop the console overlay."""
if self._console_visible:
# Pop the console -- dismiss triggers the callback
try:
self.pop_screen()
except Exception:
self._console_visible = False
else:
self.push_screen("console-overlay", callback=self._on_console_dismissed)
self._console_visible = True
def _on_console_dismissed(self, _result=None) -> None:
"""Called when the console overlay is dismissed."""
self._console_visible = False
# ------------------------------------------------------------------
# Tab bar button handling
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id.startswith("tab-"):
tab = button_id.removeprefix("tab-")
if tab in TABS:
self.action_switch_tab(tab)
# ------------------------------------------------------------------
# QuickActions navigation (from Dashboard)
# ------------------------------------------------------------------
def on_quick_actions_action_selected(self, event) -> None:
"""Handle navigation requests from the Dashboard's quick actions."""
action = event.action
if action == "point":
self.action_switch_tab("control")
elif action == "monitor":
self.action_switch_tab("signal")
elif action == "scan":
self.action_switch_tab("signal")
# Tell the signal screen to switch to skymap mode
try:
signal_screen = self.query_one("#signal")
if hasattr(signal_screen, "switch_mode"):
signal_screen.switch_mode("skymap")
except Exception:
pass
elif action == "stow":
self._do_stow()
def _do_stow(self) -> None:
"""Move dish to stow position (0, 65)."""
if self.device is None:
self.notify("No device connected", severity="warning")
return
self.notify("Stowing dish to AZ=0 EL=65...", severity="information")
self._run_stow()
@work(thread=True)
def _run_stow(self) -> None:
"""Execute stow in a worker thread."""
try:
self.device.move_to(0.0, 65.0)
self.call_from_thread(self.notify, "Stow command sent")
except Exception:
log.exception("Stow failed")
self.call_from_thread(self.notify, "Stow failed", severity="error")
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def on_unmount(self) -> None:
"""Signal all worker threads to exit and disconnect the device."""
self.shutdown_event.set()
if self.device and hasattr(self.device, "disconnect"):
self.device.disconnect()
def action_toggle_dark(self) -> None:
self.dark = not self.dark
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id.startswith("btn-"):
mode = button_id.removeprefix("btn-")
if mode in MODES:
self.action_switch_mode(mode)
def main() -> None:
parser = argparse.ArgumentParser(
@ -176,5 +323,6 @@ def main() -> None:
pass
finally:
app.shutdown_event.set()
if app.device and hasattr(app.device, "disconnect"):
app.device.disconnect()
with contextlib.suppress(Exception):
if app.device and hasattr(app.device, "disconnect"):
app.device.disconnect()