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:
parent
e7e71c47d7
commit
145763fcfb
23 changed files with 2796 additions and 865 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue