Add radar scope, splash screen, Star Wars easter egg, and harden TUI

New features:
- P1 green phosphor radar scope widget on Track screen with LUT-optimized rendering
- Splash screen with pre-baked ANSI half-block art from 16colo.rs/Mistigris
- Star Wars ASCII animation via telnet (ctrl+w) with IPv6 happy eyeballs and offline fallback
- Dark Side / New Hope toast notifications on theme toggle
- Kitty terminal detection with cat emoji on splash

Robustness (from Apollo code review):
- Circuit breaker in track loop after 10 consecutive errors
- Input validation for frequency, symbol rate, step size across scan/spectrum/track
- Consolidated sys.path manipulation into __init__.py
- Radar scope pre-computes dist/angle LUT per pixel on resize

Cleanup:
- Removed unused imports across lband, monitor, scan, signal_gauge
- Moved Pillow/textual-image to optional dev deps (splash uses pre-baked ANSI)
- Added 41-test pytest suite covering telnet IAC parsing, radar geometry, splash assets
This commit is contained in:
Ryan Malloy 2026-02-14 09:51:58 -07:00
parent 8da486719a
commit 6dcb6b693a
33 changed files with 2076 additions and 86 deletions

View file

@ -1,3 +1,12 @@
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
import sys
from pathlib import Path
__version__ = "0.1.0"
# Ensure skywalker_lib (in sibling tools/ directory) is importable.
# Consolidated here so app.py and status_bar.py don't duplicate this.
_tools_dir = Path(__file__).resolve().parent.parent.parent.parent / "tools"
if _tools_dir.is_dir() and str(_tools_dir) not in sys.path:
sys.path.insert(0, str(_tools_dir))

View file

@ -9,13 +9,6 @@ with Textual's built-in App.mode / _current_mode / _screen_stacks system.
import argparse
import sys
import os
from pathlib import Path
# Add tools directory to path for skywalker_lib import
_tools_dir = str(Path(__file__).resolve().parent.parent.parent.parent / "tools")
if _tools_dir not in sys.path:
sys.path.insert(0, _tools_dir)
from textual.app import App, ComposeResult
from textual.binding import Binding
@ -57,14 +50,17 @@ class SkyWalkerApp(App):
Binding("f5", "rf_mode('track')", "Track", 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"):
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()
@ -81,18 +77,35 @@ class SkyWalkerApp(App):
yield Footer()
def on_mount(self) -> None:
# Initialize status bar
# Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge)
# Install all mode screens into the content switcher
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 5 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)
# Activate initial mode
self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None:
@ -110,7 +123,7 @@ class SkyWalkerApp(App):
btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_class("-active")
self.sub_title = f"DVB-S RF Tool {MODES[mode][0]}"
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."""
@ -122,6 +135,25 @@ class SkyWalkerApp(App):
def action_toggle_dark(self) -> None:
self.dark = not self.dark
if self.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():
@ -133,6 +165,10 @@ def main():
"--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()),
@ -158,7 +194,11 @@ def main():
print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1)
app = SkyWalkerApp(bridge=bridge, initial_mode=args.mode)
app = SkyWalkerApp(
bridge=bridge,
initial_mode=args.mode,
show_splash=not args.no_splash,
)
try:
app.run()
finally:

View file

View file

@ -0,0 +1,13 @@
# Splash Art Credits
All artwork sourced from [16colo.rs](https://16colo.rs) /
[Mistigris](https://mistigris.com) art packs.
| File | Artist | Title | Pack |
|------|--------|-------|------|
| `seti-satellite.png` | Illarterate | S.E.T.I. Satellite | [mist0717](https://16colo.rs/pack/mist0717) |
| `dialtone.jpg` | 192.168.10.13 | Dialtone | [mist1119](https://16colo.rs/pack/mist1119) |
| `so-far-away.gif` | Blippypixel | So Far Away | [mist0523](https://16colo.rs/pack/mist0523) |
| `prodigy-out-of-space.jpg` | Jellica Jake | Prodigy / Out of Space | [mist0121](https://16colo.rs/pack/mist0121) |
| `space-docker.gif` | Blippypixel | Space Docker | [mist1120](https://16colo.rs/pack/mist1120) |

View file

@ -0,0 +1,27 @@
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -0,0 +1,40 @@
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,32 @@
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,32 @@
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,32 @@
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -4,14 +4,9 @@ Same sweep mechanics as the spectrum screen, but with LNB disabled (direct input
and band allocation overlays showing what service each frequency range belongs to.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar, Checkbox
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker

View file

@ -9,7 +9,7 @@ from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker, WorkerState
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
from skywalker_tui.widgets.sparkline_widget import SparklineWidget

View file

@ -4,9 +4,6 @@ Multi-phase pipeline: coarse sweep → peak detection → fine sweep → blind s
Shows progress, spectrum visualization, and a results table.
"""
import struct
import time
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
@ -157,7 +154,6 @@ class ScanScreen(Container):
# Phase 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Phase 1: Coarse sweep", 0)
coarse_step = 10
total_steps = max(1, int((stop - start) / coarse_step) + 1)
def coarse_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100

View file

@ -109,15 +109,30 @@ class SpectrumScreen(Container):
def _start_sweep(self) -> None:
if self._sweeping:
return
self._sweeping = True
start = float(self.query_one("#spec-start", Input).value or "950")
stop = float(self.query_one("#spec-stop", Input).value or "2150")
step = float(self.query_one("#spec-step", Input).value or "5")
dwell = int(self.query_one("#spec-dwell", Input).value or "10")
lnb_lo = float(self.query_one("#spec-lnb", Input).value or "0")
# Validate inputs
try:
start = float(self.query_one("#spec-start", Input).value or "950")
stop = float(self.query_one("#spec-stop", Input).value or "2150")
step = float(self.query_one("#spec-step", Input).value or "5")
dwell = int(float(self.query_one("#spec-dwell", Input).value or "10"))
lnb_lo = float(self.query_one("#spec-lnb", Input).value or "0")
except ValueError:
self._update_status("Invalid input — check numeric fields")
return
if not (950 <= start <= 2150) or not (950 <= stop <= 2150):
self._update_status("Frequency out of range (9502150 MHz)")
return
if start >= stop:
self._update_status("Start must be less than Stop")
return
if not (0.1 <= step <= 500):
self._update_status("Step out of range (0.1500 MHz)")
return
continuous = self.query_one("#spec-continuous", Checkbox).value
self._sweeping = True
self._sweep_worker = self._do_sweep(start, stop, step, dwell, lnb_lo, continuous)
def _stop_sweep(self) -> None:
@ -140,7 +155,6 @@ class SpectrumScreen(Container):
sweep_num = 0
while self._sweeping:
sweep_num += 1
total_steps = max(1, int((stop - start) / step) + 1)
step_count = [0]
def progress_cb(freq, step_num, total, result):

View file

@ -0,0 +1,151 @@
"""Splash screen — renders 16colo.rs art on startup.
Pre-baked ANSI half-block art (.ans files) display instantly no runtime
image decoding or terminal protocol detection needed. Generated by
scripts/prebake_splash.py from the original artwork.
Randomly selects from bundled artwork on each launch. Auto-dismisses
after 5 seconds or on any keypress.
"""
import os
import random
from pathlib import Path
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static
from rich.text import Text
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" / "splash"
# Artwork catalog — stem (sans extension), artist, title
ART_CATALOG = [
("seti-satellite", "Illarterate", "S.E.T.I. Satellite"),
("dialtone", "192.168.10.13", "Dialtone"),
("so-far-away", "Blippypixel", "So Far Away"),
("prodigy-out-of-space", "Jellica Jake", "Prodigy / Out of Space"),
("space-docker", "Blippypixel", "Space Docker"),
]
def _is_kitty() -> bool:
"""Detect if running inside Kitty terminal."""
return bool(os.environ.get("KITTY_WINDOW_ID"))
class SplashScreen(Screen):
"""Full-screen splash with 16colo.rs art, auto-dismisses."""
BINDINGS = [
Binding("escape", "dismiss_splash", "Skip", show=False),
]
DEFAULT_CSS = """
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-title {
height: 1;
color: #00d4aa;
text-style: bold;
text-align: center;
dock: bottom;
margin: 0 0 2 0;
}
SplashScreen #splash-credit {
height: 1;
color: #506878;
text-align: center;
dock: bottom;
margin: 0 0 1 0;
}
SplashScreen #splash-skip {
height: 1;
color: #2a3a4a;
text-align: center;
dock: bottom;
}
SplashScreen #splash-art {
width: 100%;
height: 1fr;
content-align: center middle;
background: #000000;
}
SplashScreen #splash-fallback {
width: 100%;
height: 1fr;
content-align: center middle;
color: #00d4aa;
text-style: bold;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ans_path: Path | None = None
self._artist = ""
self._title = ""
self._select_art()
def _select_art(self) -> None:
"""Pick a random artwork from available pre-baked .ans files."""
available = []
for stem, artist, title in ART_CATALOG:
ans_path = ASSETS_DIR / f"{stem}.ans"
if ans_path.exists():
available.append((ans_path, artist, title))
if available:
self._ans_path, self._artist, self._title = random.choice(available)
def compose(self) -> ComposeResult:
with Vertical(id="splash-container"):
if self._ans_path:
ansi_content = self._ans_path.read_text()
yield Static(Text.from_ansi(ansi_content), id="splash-art")
else:
yield Static(
"S K Y W A L K E R - 1",
id="splash-fallback",
)
kitty_tag = " \U0001f431" if _is_kitty() else ""
yield Static(
f"S K Y W A L K E R - 1 / DVB-S RF Tool{kitty_tag}",
id="splash-title",
)
if self._artist:
yield Static(
f"Art by {self._artist} \u2014 16colo.rs/mistigris",
id="splash-credit",
)
yield Static("[any key to skip]", id="splash-skip")
def on_mount(self) -> None:
# Brief delay before accepting key dismissal — prevents eating
# keystrokes from the transition that pushed this screen
self._accept_keys = False
self.set_timer(0.3, self._enable_keys)
self.set_timer(5, self.action_dismiss_splash)
def _enable_keys(self) -> None:
self._accept_keys = True
def on_key(self) -> None:
if getattr(self, "_accept_keys", False):
self.action_dismiss_splash()
def action_dismiss_splash(self) -> None:
if self.is_current:
self.app.pop_screen()

View file

@ -0,0 +1,448 @@
"""Star Wars Easter egg — ASCII Star Wars via telnet with offline fallback.
Attempts to connect to towel.blinkenlights.nl:23 for the famous ASCII
Star Wars animation. If the network blocks port 23 (common), falls back
to a built-in opening crawl with ASCII art.
The telnet stream uses VT100 escape sequences:
ESC[H cursor home (frame boundary)
ESC[J clear to end of screen
ESC[...m SGR color/style codes
We detect ESC[H as "new frame", buffer the frame text, then render it
atomically via Rich's Text.from_ansi() which handles native ANSI styling.
Hidden binding: ctrl+w (W for Wars/Walker).
"""
import socket
import time
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static, RichLog
from textual import work
from rich.text import Text
TELNET_HOST = "towel.blinkenlights.nl"
TELNET_PORT = 23
RECV_SIZE = 4096
CONNECT_TIMEOUT = 8
# ── Built-in offline crawl ────────────────────────────────────────────
# Shown when telnet is unreachable. Frames are (lines, delay_seconds).
_CRAWL_FRAMES = [
# Pause on black
([""], 2.0),
# Title card
([
"",
"",
"",
" . . . . . . . . .",
"",
" A long time ago in a galaxy far,",
" far away....",
"",
"",
], 3.0),
# Clear + logo
([
"",
"",
r" ________________. ___ .______",
r" / | / \ | _ \ ",
r" | (-----| |----`/ ^ \ | |_) |",
r" \ \ | | / /_\ \ | /",
r" .-----) | | | / _____ \ | |\ \------.",
r" |________/ |__| /__/ \__\| _| `.________|",
"",
r" ____ __ ____ ___ .______ ________.",
r" \ \ / \ / / / \ | _ \ / |",
r" \ \/ \/ / / ^ \ | |_) || (-----`",
r" \ / / /_\ \ | / \ \ ",
r" \ /\ / / _____ \ | |\ \---) |",
r" \__/ \__/ /__/ \__\|__| `._______/",
"",
"",
], 4.0),
# Episode info
([
"",
"",
"",
" Episode IV.I",
"",
" A N E W F R E Q U E N C Y",
"",
"",
], 3.0),
# Opening crawl text — the SkyWalker-1 version
([
" It is a period of civil engineering.",
" Rebel hackers, armed with USB cables",
" and logic analyzers, have won their",
" first victory against the evil",
" Proprietary Firmware Empire.",
"",
" During the battle, rebel spies managed",
" to steal secret plans to the Empire's",
" ultimate weapon, the GP8PSK USB protocol,",
" a device with enough power to receive",
" an entire satellite transponder.",
"",
" Pursued by the Empire's sinister agents,",
" Princess SkyWalker races home aboard her",
" DVB-S receiver, custodian of the stolen",
" vendor commands that can save her people",
" and restore signal lock to the galaxy....",
"",
], 0.18), # per-line scroll for this frame
# Star Destroyer ASCII art
([
"",
"",
"",
" . ",
" /|\\ ",
" / | \\ ",
" / | \\ ",
" / | \\ ",
" ____/ | \\____ ",
" ____/ _______|_______ \\____ ",
" ____/ ____/ | \\____ \\____",
" _____/ ___/ | \\____\\_____ ",
" /______/=====_____________|_____________=====\\______\\",
" \\ \\============|============/ /",
" \\ \\ | / /",
" \\ |__________|__________| /",
" \\ | | | /",
" \\___|__________|__________|___/",
" \\ | /",
" \\_______/ \\_______/",
"",
"",
" >>> GENPIX SKYWALKER-1 DVB-S RECEIVER <<<",
" Firmware reversed. Signal acquired.",
"",
"",
], 3.5),
# Credits
([
"",
" ==========================================",
" Directed by .............. Ryan Malloy",
" Firmware by ........... Genpix Electronics",
" Reversed by ........... USB sniffers & gdb",
" Radar Scope by ........ P1 green phosphor",
" Theme Music by ........ 22 kHz tone burst",
"",
" ASCII Star Wars originally by:",
" Simon Jansen (asciimation.co.nz)",
" Sten Spans (blinkenlights.nl)",
" Mike Edwards (terminal tricks)",
"",
" Telnet blocked? Blame your ISP.",
" ==========================================",
"",
"",
" [ESC to close]",
"",
], 0),
]
class _TelnetStripper:
"""Stateful telnet IAC sequence stripper that handles chunk boundaries.
Telnet IAC sequences can span TCP segment boundaries. This class
buffers partial sequences across feed() calls so they're correctly
stripped even when split across recv() chunks.
"""
def __init__(self):
self._pending = b""
def feed(self, data: bytes) -> bytes:
data = self._pending + data
self._pending = b""
result = bytearray()
i = 0
while i < len(data):
if data[i] == 0xFF:
if i + 1 >= len(data):
self._pending = data[i:]
break
if data[i + 1] == 0xFF: # escaped 0xFF
result.append(0xFF)
i += 2
elif data[i + 1] in (0xFB, 0xFC, 0xFD, 0xFE): # WILL/WONT/DO/DONT
if i + 2 >= len(data):
self._pending = data[i:]
break
i += 3
elif data[i + 1] == 0xFA: # Sub-negotiation
end = data.find(b"\xff\xf0", i)
if end == -1:
self._pending = data[i:]
break
i = end + 2
else:
i += 2
else:
result.append(data[i])
i += 1
return bytes(result)
class StarWarsScreen(Screen):
"""Modal overlay streaming ASCII Star Wars from telnet, with offline fallback."""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("q", "dismiss", "Close", show=False),
]
DEFAULT_CSS = """
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
StarWarsScreen #sw-header {
height: 1;
color: #e8a020;
text-style: bold;
text-align: center;
padding: 0 1;
}
StarWarsScreen #sw-log {
height: 1fr;
background: #000000;
color: #c8d0d8;
}
StarWarsScreen #sw-footer {
height: 1;
color: #506878;
text-align: center;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._socket: socket.socket | None = None
self._running = False
def compose(self) -> ComposeResult:
with Vertical(id="sw-container"):
yield Static(
"A long time ago in a galaxy far, far away....",
id="sw-header",
)
yield RichLog(id="sw-log", wrap=False, markup=False)
yield Static("[ESC] Close", id="sw-footer")
def on_mount(self) -> None:
self._running = True
self._stream_starwars()
def on_unmount(self) -> None:
self._running = False
self._close_socket()
def action_dismiss(self) -> None:
self._running = False
self._close_socket()
self.app.pop_screen()
def _close_socket(self) -> None:
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
def _safe_write(self, text: str) -> None:
"""Write to RichLog only if screen is still mounted and running."""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.write(text)
except Exception:
pass
def _safe_clear(self) -> None:
"""Clear the RichLog."""
if not self._running or not self.is_mounted:
return
try:
self.query_one("#sw-log", RichLog).clear()
except Exception:
pass
@work(thread=True)
def _stream_starwars(self) -> None:
"""Try telnet first, fall back to built-in crawl."""
self.app.call_from_thread(
self._safe_write, "Connecting to towel.blinkenlights.nl:23..."
)
if self._try_telnet():
return # telnet worked, we're done
# Telnet failed — play the built-in crawl
self.app.call_from_thread(self._safe_write, "Falling back to local crawl...\n")
time.sleep(1.0)
self.app.call_from_thread(self._safe_clear)
self._play_offline_crawl()
def _try_telnet(self) -> bool:
"""Attempt telnet connection. Returns True if streaming completed.
Uses AF_UNSPEC to try both IPv6 and IPv4 (happy eyeballs style).
Many networks route IPv6 correctly but block or drop IPv4 on port 23.
"""
try:
addrs = socket.getaddrinfo(TELNET_HOST, TELNET_PORT,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addrs:
raise socket.gaierror("No address found")
labels = []
for fam, _, _, _, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
labels.append(f"{tag}:{sa[0]}")
self.app.call_from_thread(
self._safe_write, f"Resolved: {', '.join(labels)}"
)
except socket.gaierror as e:
self.app.call_from_thread(
self._safe_write, f"DNS failed: {e}"
)
return False
# Try each address until one connects (IPv6 first if available)
sock = None
last_err = None
for fam, socktype, proto, _canon, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
self.app.call_from_thread(
self._safe_write, f"Trying {tag}:{sa[0]}..."
)
try:
s = socket.socket(fam, socktype, proto)
s.settimeout(CONNECT_TIMEOUT)
s.connect(sa)
s.settimeout(2.0)
sock = s
break
except (socket.timeout, OSError) as e:
last_err = e
try:
s.close()
except Exception:
pass
if sock is None:
self.app.call_from_thread(
self._safe_write, f"All addresses failed: {last_err}"
)
return False
self._socket = sock
self.app.call_from_thread(self._safe_write, "Connected! Streaming...\n")
stripper = _TelnetStripper()
buffer = b""
while self._running:
try:
chunk = sock.recv(RECV_SIZE)
if not chunk:
break
clean = stripper.feed(chunk)
buffer += clean
# ESC[H (cursor home) marks frame boundaries.
# Buffer until we have a complete frame, then render atomically.
while b"\x1b[H" in buffer:
frame_data, buffer = buffer.split(b"\x1b[H", 1)
if frame_data.strip():
self.app.call_from_thread(
self._render_frame, frame_data
)
except socket.timeout:
# Timeout with no ESC[H — flush buffer as partial frame
if buffer.strip():
self.app.call_from_thread(
self._render_frame, buffer
)
buffer = b""
continue
except Exception:
break
# Flush anything remaining
if buffer.strip():
self.app.call_from_thread(self._render_frame, buffer)
self.app.call_from_thread(self._safe_write, "\n[Stream ended]")
self._close_socket()
return True
def _render_frame(self, data: bytes) -> None:
"""Render a complete animation frame, replacing previous content.
Uses Rich's Text.from_ansi() to natively handle any ANSI styling
in the stream. ESC[J (clear) is stripped since we replace the
whole frame anyway.
"""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.clear()
# Decode frame, strip ESC[J (redundant — we clear anyway)
text = data.replace(b"\x1b[J", b"")
text = text.decode("ascii", errors="replace")
text = text.replace("\r", "")
# Rich's from_ansi() renders ANSI color/style codes natively
log.write(Text.from_ansi(text))
except Exception:
pass
def _play_offline_crawl(self) -> None:
"""Play the built-in Star Wars opening crawl frame by frame."""
for lines, delay in _CRAWL_FRAMES:
if not self._running:
return
# The opening crawl frame scrolls line-by-line
if delay > 0 and delay < 0.5 and len(lines) > 3:
# Per-line scroll mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
time.sleep(delay)
else:
# Full-frame mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
if delay > 0:
time.sleep(delay)

View file

@ -1,13 +1,14 @@
"""Track screen — carrier/beacon tracker with logging and export.
"""Track screen — carrier/beacon tracker with radar scope, logging, and export.
Locks to a single frequency and records SNR, power, lock state over time.
Displays dual sparklines, an event log for lock transitions, and stats.
Supports CSV/JSONL export.
Features a hero radar scope (P1 phosphor CRT aesthetic), dual sparklines,
event log for lock transitions, and stats. Supports CSV/JSONL export.
"""
import csv
import json
import time
from collections import deque
from datetime import datetime
from pathlib import Path
@ -17,12 +18,12 @@ from textual.widgets import Label, Input, Button, Static, RichLog
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
from skywalker_tui.widgets.radar_scope import RadarScope
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class TrackScreen(Container):
"""Long-running carrier tracker with event log and export."""
"""Long-running carrier tracker with radar scope and event log."""
DEFAULT_CSS = """
TrackScreen {
@ -30,18 +31,27 @@ class TrackScreen(Container):
}
TrackScreen #track-main {
height: 1fr;
layout: vertical;
layout: horizontal;
padding: 1 2;
}
TrackScreen #track-upper {
height: auto;
layout: horizontal;
}
TrackScreen #track-gauge-col {
TrackScreen #track-radar-col {
width: 1fr;
min-width: 30;
layout: vertical;
}
TrackScreen #track-radar-label {
height: 1;
color: #0a5a10;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-data-col {
width: 1fr;
layout: vertical;
padding: 0 0 0 1;
}
TrackScreen #track-sparklines {
width: 1fr;
height: auto;
layout: vertical;
}
TrackScreen #track-log-container {
@ -101,28 +111,30 @@ class TrackScreen(Container):
self._peak_snr = 0.0
self._start_time = 0.0
self._was_locked: bool | None = None
self._records: list[dict] = []
self._records: deque[dict] = deque(maxlen=360_000) # ~10h at 10Hz
def compose(self) -> ComposeResult:
with Vertical(id="track-main"):
with Horizontal(id="track-upper"):
with Vertical(id="track-gauge-col"):
yield SignalGauge(id="track-gauge")
with Horizontal(id="track-main"):
with Vertical(id="track-radar-col"):
yield Static("[#0a5a10 bold]Radar Scope[/]", id="track-radar-label")
yield RadarScope(id="track-radar")
with Vertical(id="track-data-col"):
with Vertical(id="track-sparklines"):
yield SparklineWidget(title="SNR (dB)", color="#00d4aa",
id="track-snr-spark")
yield SparklineWidget(title="Power (dB)", color="#2196f3",
id="track-power-spark")
with Vertical(id="track-log-container"):
yield Static("[#00d4aa bold]Event Log[/]", id="track-log-title")
yield RichLog(id="track-log", wrap=True, markup=True)
with Vertical(id="track-log-container"):
yield Static("[#00d4aa bold]Event Log[/]", id="track-log-title")
yield RichLog(id="track-log", wrap=True, markup=True)
with Horizontal(id="track-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="trk-samples")
yield Static("[#506878]Elapsed:[/] [#00d4aa]0s[/]", id="trk-elapsed")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="trk-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="trk-status")
with Horizontal(id="track-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="trk-samples")
yield Static("[#506878]Elapsed:[/] [#00d4aa]0s[/]", id="trk-elapsed")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="trk-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="trk-status")
with Horizontal(id="track-controls"):
yield Label("Freq (MHz):")
@ -156,6 +168,36 @@ class TrackScreen(Container):
def _start_tracking(self) -> None:
if self._tracking:
return
log = self.query_one("#track-log", RichLog)
# C3: Validate inputs before starting
try:
freq = float(self.query_one("#trk-freq", Input).value or "1200")
except ValueError:
log.write("[bold #e04040]Invalid frequency — must be a number[/]")
return
try:
sr = int(float(self.query_one("#trk-sr", Input).value or "20000"))
except ValueError:
log.write("[bold #e04040]Invalid symbol rate — must be a number[/]")
return
try:
rate = float(self.query_one("#trk-rate", Input).value or "1")
except ValueError:
log.write("[bold #e04040]Invalid rate — must be a number[/]")
return
if not (950 <= freq <= 2150):
log.write("[bold #e04040]Frequency out of range (9502150 MHz)[/]")
return
if not (256 <= sr <= 30000):
log.write("[bold #e04040]Symbol rate out of range (25630000 ksps)[/]")
return
if not (0.1 <= rate <= 100):
log.write("[bold #e04040]Rate out of range (0.1100 Hz)[/]")
return
self._tracking = True
self._sample_count = 0
self._peak_snr = 0.0
@ -163,14 +205,9 @@ class TrackScreen(Container):
self._records.clear()
self._start_time = time.monotonic()
freq = float(self.query_one("#trk-freq", Input).value or "1200")
sr = int(self.query_one("#trk-sr", Input).value or "20000")
rate = float(self.query_one("#trk-rate", Input).value or "1")
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Tracking[/]"
)
log = self.query_one("#track-log", RichLog)
log.clear()
log.write("[#506878]Tracking started[/]")
@ -192,7 +229,8 @@ class TrackScreen(Container):
@work(thread=True)
def _do_track(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background tracking loop."""
"""Background tracking loop with circuit breaker."""
max_consecutive_errors = 10
interval = 1.0 / max(0.1, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
@ -201,14 +239,29 @@ class TrackScreen(Container):
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception:
pass
except Exception as e:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Boot/tune failed:[/] [#e04040]{e}[/]",
)
consecutive_errors = 0
while self._tracking:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
consecutive_errors = 0 # reset on success
except Exception:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Circuit breaker: "
f"{max_consecutive_errors} consecutive errors, stopping[/]",
)
self._tracking = False
self.app.call_from_thread(self._mark_stopped)
return
time.sleep(interval)
continue
@ -251,10 +304,19 @@ class TrackScreen(Container):
if not self.is_mounted:
return
self.query_one("#track-gauge", SignalGauge).update_signal(sig)
self.query_one("#track-snr-spark", SparklineWidget).push(sig.get("snr_db", 0))
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
# Feed radar scope
radar = self.query_one("#track-radar", RadarScope)
radar.push(snr_db)
radar.set_locked(locked)
# Feed sparklines
self.query_one("#track-snr-spark", SparklineWidget).push(snr_db)
self.query_one("#track-power-spark", SparklineWidget).push(sig.get("power_db", -40))
# Update stats
self.query_one("#trk-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
@ -278,23 +340,53 @@ class TrackScreen(Container):
f"[#506878]{ts}[/] [bold #e04040]LOCK LOST[/]"
)
def _log_event(self, markup: str) -> None:
"""Write a message to the event log (safe from any thread via call_from_thread)."""
if not self.is_mounted:
return
try:
self.query_one("#track-log", RichLog).write(markup)
except Exception:
pass
def _mark_stopped(self) -> None:
"""Update UI to stopped state (called from circuit breaker)."""
if not self.is_mounted:
return
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #e04040]Error[/]"
)
except Exception:
pass
def _export_csv(self) -> None:
if not self._records:
return
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.csv")
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(self._records[0].keys()))
w.writeheader()
w.writerows(self._records)
log = self.query_one("#track-log", RichLog)
log.write(f"[#00d4aa]CSV exported: {path}[/]")
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.csv")
try:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(self._records[0].keys()))
w.writeheader()
w.writerows(self._records)
log.write(f"[#00d4aa]CSV exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
def _export_jsonl(self) -> None:
if not self._records:
return
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.jsonl")
with open(path, "w") as f:
for rec in self._records:
f.write(json.dumps(rec) + "\n")
log = self.query_one("#track-log", RichLog)
log.write(f"[#00d4aa]JSONL exported: {path}[/]")
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.jsonl")
try:
with open(path, "w") as f:
for rec in self._records:
f.write(json.dumps(rec) + "\n")
log.write(f"[#00d4aa]JSONL exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")

View file

@ -303,3 +303,52 @@ ProgressBar Bar {
.right-panel {
width: 1fr;
}
/* ─── Radar scope ─── */
RadarScope {
min-height: 12;
min-width: 24;
height: 1fr;
background: #0a0a0a;
border: round #0a2a0a;
}
#track-radar-col {
width: 1fr;
min-width: 30;
}
/* ─── Splash screen overlay ─── */
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-image {
width: 100%;
height: 1fr;
content-align: center middle;
}
/* ─── Star Wars overlay ─── */
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}

View file

@ -0,0 +1,276 @@
"""Radar scope widget — P1 green phosphor CRT aesthetic.
Renders a circular radar display using half-block characters for 2x vertical
resolution. Each terminal cell becomes two pixel rows via the character
with independent fg (top) and bg (bottom) colors.
The sweep beam rotates through 360 positions. Signal strength determines
radial distance from center. Older samples decay in brightness (phosphor
persistence). Concentric range rings and a crosshair provide scale reference.
"""
import math
from collections import deque
from textual.widget import Widget
from textual.strip import Strip
from rich.segment import Segment
from rich.style import Style
# P1 green phosphor palette — 8 intensity levels from dead to peak burn
PHOSPHOR = [
"#0a0a0a", # background / dead pixel
"#0a1a0a", # very faint trace
"#0a2a0a", # dim afterglow
"#0a3a0a", # fading
"#0a4a0f", # moderate
"#0a5a10", # bright trace
"#0a6a15", # very bright
"#10ff30", # peak phosphor burn
]
PHOSPHOR_STYLES = [Style(color=c) for c in PHOSPHOR]
BG_COLOR = "#0a0a0a"
BG_STYLE = Style(color=BG_COLOR, bgcolor=BG_COLOR)
RING_COLOR = "#0a2a0a"
CROSSHAIR_COLOR = "#0a3a0a"
LOCK_RING_COLOR = "#10ff30"
class RadarScope(Widget):
"""Circular radar scope with phosphor decay and sweep beam."""
DEFAULT_CSS = """
RadarScope {
min-height: 12;
min-width: 24;
background: #0a0a0a;
}
"""
def __init__(self, max_samples: int = 360, **kwargs):
super().__init__(**kwargs)
self._samples: deque[float] = deque([0.0] * max_samples, maxlen=max_samples)
self._max_samples = max_samples
self._angle_idx = 0 # current sweep position (0..max_samples-1)
self._locked = False
# Pre-computed geometry + LUTs (rebuilt on resize)
self._cx = 0.0
self._cy = 0.0
self._radius = 0.0
self._cols = 0
self._rows = 0 # pixel rows (2x terminal rows)
# Per-pixel LUTs: distance from center, angle-to-sample index
self._dist_lut: list[list[float]] = []
self._angle_lut: list[list[int]] = []
# Per-pixel dx/dy for crosshair checks
self._dx_lut: list[list[float]] = []
self._dy_lut: list[list[float]] = []
def push(self, snr_db: float, max_snr: float = 16.0) -> None:
"""Push a new signal sample. SNR is normalized to 0.0-1.0 range."""
normalized = max(0.0, min(1.0, snr_db / max(0.1, max_snr)))
self._samples.append(normalized)
self._angle_idx = (self._angle_idx + 1) % self._max_samples
self.refresh()
def set_locked(self, locked: bool) -> None:
if locked != self._locked:
self._locked = locked
self.refresh()
def _recompute_geometry(self, width: int, height: int) -> None:
"""Recompute center, radius, and per-pixel LUTs for current dimensions.
Pre-computes dist/angle for every pixel so render_line() avoids
math.sqrt/math.atan2 per pixel per frame ~O(1) lookup instead.
"""
self._cols = width
self._rows = height * 2 # 2x vertical resolution via half-blocks
# Radius fits within both dimensions, accounting for ~2:1 terminal char aspect
rx = (width - 2) / 2.0
ry = (height * 2 - 2) / 2.0
self._radius = min(rx, ry)
cx = width / 2.0
cy = height # center in pixel rows (height * 2 / 2)
self._cx = cx
self._cy = cy
# Build LUTs for all pixel rows (height * 2) × columns
pixel_rows = height * 2
dist_lut = []
angle_lut = []
dx_lut = []
dy_lut = []
sqrt = math.sqrt
atan2 = math.atan2
degrees = math.degrees
ms = self._max_samples
for py in range(pixel_rows):
d_row = []
a_row = []
dxr = []
dyr = []
dy = py - cy
for px in range(width):
dx = px - cx
dist = sqrt(dx * dx + dy * dy)
d_row.append(dist)
dxr.append(dx)
dyr.append(dy)
if dist >= 1.0:
angle = atan2(dy, dx)
angle_deg = (degrees(angle) + 360) % 360
a_row.append(int(angle_deg / 360 * ms) % ms)
else:
a_row.append(0) # center pixel — angle irrelevant
dist_lut.append(d_row)
angle_lut.append(a_row)
dx_lut.append(dxr)
dy_lut.append(dyr)
self._dist_lut = dist_lut
self._angle_lut = angle_lut
self._dx_lut = dx_lut
self._dy_lut = dy_lut
def render_line(self, y: int) -> Strip:
"""Render one terminal row of the radar scope."""
width = self.size.width
height = self.size.height
if width < 4 or height < 4:
return Strip([Segment(" " * width, BG_STYLE)])
if self._cols != width or self._rows != height * 2:
self._recompute_geometry(width, height)
radius = self._radius
if radius < 2:
return Strip([Segment(" " * width, BG_STYLE)])
# Snapshot mutable state for consistent rendering across the frame
samples = list(self._samples)
angle_idx = self._angle_idx
locked = self._locked
# Two pixel rows per terminal row
py_top = y * 2
py_bot = y * 2 + 1
# LUT rows for this terminal row
dist_top = self._dist_lut[py_top] if py_top < len(self._dist_lut) else None
dist_bot = self._dist_lut[py_bot] if py_bot < len(self._dist_lut) else None
angle_top = self._angle_lut[py_top] if py_top < len(self._angle_lut) else None
angle_bot = self._angle_lut[py_bot] if py_bot < len(self._angle_lut) else None
dx_top = self._dx_lut[py_top] if py_top < len(self._dx_lut) else None
dx_bot = self._dx_lut[py_bot] if py_bot < len(self._dx_lut) else None
dy_top = self._dy_lut[py_top] if py_top < len(self._dy_lut) else None
dy_bot = self._dy_lut[py_bot] if py_bot < len(self._dy_lut) else None
if not dist_top or not dist_bot:
return Strip([Segment(" " * width, BG_STYLE)])
_pi = self._pixel_intensity
segments = []
for x in range(width):
top_intensity = _pi(
dist_top[x], angle_top[x], dx_top[x], dy_top[x],
radius, samples, angle_idx, locked,
)
bot_intensity = _pi(
dist_bot[x], angle_bot[x], dx_bot[x], dy_bot[x],
radius, samples, angle_idx, locked,
)
top_color = PHOSPHOR[top_intensity]
bot_color = PHOSPHOR[bot_intensity]
# ▀ char: fg = top pixel, bg = bottom pixel
style = Style(color=top_color, bgcolor=bot_color)
segments.append(Segment("\u2580", style))
return Strip(segments)
def _pixel_intensity(self, dist: float, sample_idx: int,
dx: float, dy: float,
radius: float, samples: list[float],
angle_idx: int, locked: bool) -> int:
"""Compute phosphor intensity (0-7) for a single pixel.
Uses pre-computed dist/sample_idx/dx/dy from LUTs (no trig per pixel).
Uses snapshot data (samples, angle_idx, locked) rather than mutable
instance state for frame-consistent rendering.
"""
# Outside the scope circle
if dist > radius + 1:
return 0
# Lock ring — outer edge glow
if locked and abs(dist - radius) < 1.5:
return 7
# Range rings at 25%, 50%, 75% radius
for ring_r in (0.25, 0.5, 0.75):
if abs(dist - radius * ring_r) < 0.7:
return 2 # dim ring
# Outer boundary ring
if abs(dist - radius) < 0.7:
return 2
# Crosshair (horizontal and vertical through center)
if abs(dx) < 0.7 and dist < radius:
return 2
if abs(dy) < 0.7 and dist < radius:
return 2
# Signal trace — map pixel angle to sample buffer
if dist < 1.0:
return 1 # center dot
if dist > radius:
return 0
# sample_idx already computed in LUT via atan2 → degrees → index
strength = samples[sample_idx]
# Signal renders as a blip at radial distance proportional to strength
signal_dist = strength * radius * 0.85 # 85% of radius at max
noise_floor_dist = radius * 0.05 # tiny noise floor ring
# Distance from the signal blip center
blip_dist = abs(dist - signal_dist)
noise_dist = abs(dist - noise_floor_dist)
# Age-based decay: how far behind the sweep beam is this angle?
age = (angle_idx - sample_idx) % self._max_samples
age_ratio = age / self._max_samples # 0 = newest, 1 = oldest
# Intensity from signal blip proximity
if strength > 0.02 and blip_dist < 2.0:
proximity = max(0.0, 1.0 - blip_dist / 2.0)
freshness = max(0.0, 1.0 - age_ratio * 1.2)
raw_intensity = proximity * freshness * strength
return max(1, min(7, int(raw_intensity * 7 + 0.5)))
# Sweep beam — the most recent angle is brightest
if age < 3:
beam_intensity = max(0.0, 1.0 - age / 3.0)
if dist < radius * 0.9:
return max(1, min(5, int(beam_intensity * 5)))
# Noise floor glow
if noise_dist < 1.0 and strength > 0:
return 1
# Fill between center and signal (faint trail)
if strength > 0.1 and dist < signal_dist and age_ratio < 0.5:
trail = max(0.0, (1.0 - age_ratio * 2) * 0.3)
if trail > 0.05:
return 1
return 0

View file

@ -2,7 +2,7 @@
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Label, Static
from textual.widgets import Static
from textual.reactive import reactive

View file

@ -1,12 +1,6 @@
"""Device status bar — connection state, firmware version, config bits."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widget import Widget
from textual.widgets import Label