Ryan Malloy bbdcb243dc Normalize line endings to LF across entire repository
Apply .gitattributes normalization to convert all CRLF line
endings inherited from Windows-origin source files to Unix LF.
175 files, zero content changes.
2026-02-20 10:55:50 -07:00

223 lines
8.0 KiB
Python

"""SkyWalker-1 TUI — main application.
Provides mode switching between 8 operating modes via a sidebar and F-key
shortcuts. Each mode is a Container subclass that manages its own workers.
Note: We use "rf_mode" terminology for our operating modes to avoid colliding
with Textual's built-in App.mode / _current_mode / _screen_stacks system.
"""
import argparse
import sys
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Button, Label, Static, ContentSwitcher
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
from skywalker_tui.widgets.status_bar import DeviceStatusBar
from skywalker_tui.screens.spectrum import SpectrumScreen
from skywalker_tui.screens.scan import ScanScreen
from skywalker_tui.screens.monitor import MonitorScreen
from skywalker_tui.screens.lband import LBandScreen
from skywalker_tui.screens.track import TrackScreen
from skywalker_tui.screens.device import DeviceScreen
from skywalker_tui.screens.stream import StreamScreen
from skywalker_tui.screens.config import ConfigScreen
from skywalker_tui.screens.motor import MotorScreen
from skywalker_tui.screens.survey import SurveyScreen
MODES = {
"spectrum": ("F1 Spectrum", SpectrumScreen),
"scan": ("F2 Scan", ScanScreen),
"monitor": ("F3 Monitor", MonitorScreen),
"lband": ("F4 L-Band", LBandScreen),
"track": ("F5 Track", TrackScreen),
"device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
}
class SkyWalkerApp(App):
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
TITLE = "SkyWalker-1"
SUB_TITLE = "DVB-S RF Tool"
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "rf_mode('spectrum')", "Spectrum", show=True),
Binding("f2", "rf_mode('scan')", "Scan", show=True),
Binding("f3", "rf_mode('monitor')", "Monitor", show=True),
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
Binding("f5", "rf_mode('track')", "Track", show=True),
Binding("f6", "rf_mode('device')", "Device", show=True),
Binding("f7", "rf_mode('stream')", "Stream", show=True),
Binding("f8", "rf_mode('config')", "Config", show=True),
Binding("f9", "rf_mode('motor')", "Motor", show=True),
Binding("f10", "rf_mode('survey')", "Survey", show=True),
Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", show=True),
Binding("ctrl+w", "starwars", "Star Wars", show=False),
]
def __init__(self, bridge: USBBridge, initial_mode: str = "spectrum",
show_splash: bool = True):
super().__init__()
self._bridge = bridge
self._initial_rf_mode = initial_mode
self._active_rf_mode = initial_mode
self._rf_screens: dict[str, object] = {}
self._show_splash = show_splash
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield Label("[bold #00d4aa]SkyWalker-1[/]", classes="sidebar-heading")
yield Label("[#506878]DVB-S RF Tool[/]", classes="sidebar-heading")
yield Static("")
for mode_key, (label, _cls) in MODES.items():
yield Button(label, id=f"btn-{mode_key}", classes="mode-button")
yield Static("")
yield DeviceStatusBar(self._bridge)
yield ContentSwitcher(id="content-area")
yield Footer()
def on_mount(self) -> None:
# Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge)
if self._show_splash:
# Push splash FIRST, then init mode screens behind it.
# Two-tick chain: tick 1 = splash renders, tick 2 = heavy work.
self.call_later(self._push_splash)
else:
self.call_later(self._init_mode_screens)
def _push_splash(self) -> None:
"""Push splash screen, then defer heavy mode screen init."""
from skywalker_tui.screens.splash import SplashScreen
try:
self.push_screen(SplashScreen())
except Exception:
pass
# Mode screens mount behind the splash overlay — pre-baked ANSI art
# renders instantly so no delay needed before heavy work starts
self.call_later(self._init_mode_screens)
def _init_mode_screens(self) -> None:
"""Mount all 8 mode screens into the content switcher."""
switcher = self.query_one("#content-area", ContentSwitcher)
for mode_key, (_label, cls) in MODES.items():
screen = cls(self._bridge, id=f"screen-{mode_key}")
self._rf_screens[mode_key] = screen
switcher.mount(screen)
self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None:
"""Switch to a different RF operating mode."""
if mode not in MODES:
return
self._active_rf_mode = mode
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = f"screen-{mode}"
# Update sidebar button highlights
for mode_key in MODES:
btn = self.query_one(f"#btn-{mode_key}", Button)
btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_class("-active")
self.sub_title = f"DVB-S RF Tool \u2014 {MODES[mode][0]}"
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle sidebar mode button clicks."""
btn_id = event.button.id or ""
if btn_id.startswith("btn-"):
mode = btn_id[4:]
if mode in MODES:
self.action_rf_mode(mode)
def action_toggle_dark(self) -> None:
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
if self.current_theme.dark:
self.notify(
"Welcome to the Dark Side.",
title="The Force is strong with this one",
severity="warning",
timeout=4,
)
else:
self.notify(
"The Force awakens.",
title="A New Hope",
severity="information",
timeout=4,
)
def action_starwars(self) -> None:
"""Easter egg: stream ASCII Star Wars from telnet."""
from skywalker_tui.screens.starwars import StarWarsScreen
self.push_screen(StarWarsScreen())
def main():
parser = argparse.ArgumentParser(
prog="skywalker-tui",
description="Textual TUI for Genpix SkyWalker-1 DVB-S receiver",
)
parser.add_argument(
"--demo", action="store_true",
help="Use synthetic signal data (no hardware required)",
)
parser.add_argument(
"--no-splash", action="store_true",
help="Skip the splash screen on startup",
)
parser.add_argument(
"mode", nargs="?", default="spectrum",
choices=list(MODES.keys()),
help="Initial mode (default: spectrum)",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="Verbose USB logging (hardware mode only)",
)
args = parser.parse_args()
if args.demo:
device = DemoDevice()
bridge = USBBridge(device)
else:
try:
from skywalker_lib import SkyWalker1
device = SkyWalker1(verbose=args.verbose)
device.open()
bridge = USBBridge(device)
except Exception as e:
print(f"Cannot open SkyWalker-1: {e}", file=sys.stderr)
print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1)
app = SkyWalkerApp(
bridge=bridge,
initial_mode=args.mode,
show_splash=not args.no_splash,
)
try:
app.run()
finally:
bridge.close()