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

99
tui/scripts/fetch_art.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Download splash art from 16colo.rs for SkyWalker-1 TUI.
Fetches pixel/teletext art from Mistigris packs on 16colo.rs and saves
them as bundled assets for the splash screen.
Usage:
python scripts/fetch_art.py
"""
import urllib.request
import sys
from pathlib import Path
ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash"
ART_SOURCES = [
{
"url": "https://16colo.rs/pack/mist0717/raw/ILLARTERATE-SETI-100_02_SATELLITE.PNG",
"filename": "seti-satellite.png",
"artist": "Illarterate",
"title": "S.E.T.I. Satellite",
"pack": "mist0717",
},
{
"url": "https://16colo.rs/pack/mist1119/raw/192.168.10.13-DIALTONE.JPG",
"filename": "dialtone.jpg",
"artist": "192.168.10.13",
"title": "Dialtone",
"pack": "mist1119",
},
{
"url": "https://16colo.rs/pack/mist0523/raw/BLIPPYPIXEL-SO_FAR_AWAY.GIF",
"filename": "so-far-away.gif",
"artist": "Blippypixel",
"title": "So Far Away",
"pack": "mist0523",
},
{
"url": "https://16colo.rs/pack/mist0121/raw/JELLICA_JAKE-PRODIGY.JPG",
"filename": "prodigy-out-of-space.jpg",
"artist": "Jellica Jake",
"title": "Prodigy / Out of Space",
"pack": "mist0121",
},
{
"url": "https://16colo.rs/pack/mist1120/raw/BLIPPYPIXEL-SPACE_DOCKER.GIF",
"filename": "space-docker.gif",
"artist": "Blippypixel",
"title": "Space Docker",
"pack": "mist1120",
},
]
def fetch_all():
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
downloaded = 0
for art in ART_SOURCES:
dest = ASSETS_DIR / art["filename"]
if dest.exists():
print(f" exists: {art['filename']}", file=sys.stderr)
downloaded += 1
continue
print(f" fetch: {art['filename']} from {art['url']}", file=sys.stderr)
try:
req = urllib.request.Request(art["url"], headers={"User-Agent": "SkyWalker-1-TUI/0.1"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
dest.write_bytes(data)
print(f" {len(data):,} bytes -> {dest.name}", file=sys.stderr)
downloaded += 1
except Exception as e:
print(f" FAIL: {art['filename']}: {e}", file=sys.stderr)
# Write CREDITS.md
credits = ASSETS_DIR / "CREDITS.md"
lines = ["# Splash Art Credits\n", ""]
lines.append("All artwork sourced from [16colo.rs](https://16colo.rs) /")
lines.append("[Mistigris](https://mistigris.com) art packs.\n")
lines.append("| File | Artist | Title | Pack |")
lines.append("|------|--------|-------|------|")
for art in ART_SOURCES:
pack_url = f"https://16colo.rs/pack/{art['pack']}"
lines.append(
f"| `{art['filename']}` | {art['artist']} "
f"| {art['title']} | [{art['pack']}]({pack_url}) |"
)
lines.append("")
credits.write_text("\n".join(lines))
print(f"\n {downloaded}/{len(ART_SOURCES)} images downloaded to {ASSETS_DIR}", file=sys.stderr)
return downloaded
if __name__ == "__main__":
fetch_all()

View file

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Pre-render splash art as ANSI half-block text for instant display.
Converts source images (PNG/JPG/GIF) into .ans files using Unicode half-block
characters with 24-bit ANSI color. This eliminates the runtime Pillow decode
and textual-image protocol detection, making splash display instant.
Each terminal cell renders two vertical pixels using the upper-half-block
character (U+2580 '') with fg=top_pixel, bg=bottom_pixel for 2x vertical
resolution.
Usage:
python scripts/prebake_splash.py [--width 80]
"""
import argparse
from pathlib import Path
from PIL import Image
ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash"
# Source images to pre-bake
SOURCE_FILES = [
"seti-satellite.png",
"dialtone.jpg",
"so-far-away.gif",
"prodigy-out-of-space.jpg",
"space-docker.gif",
]
def image_to_ansi(img_path: Path, width: int = 80) -> str:
"""Convert an image to ANSI half-block art.
Uses (upper half block) with fg=top pixel, bg=bottom pixel to get
2x vertical resolution. Emits 24-bit ANSI color escape sequences,
optimized to only change fg/bg when the color actually changes.
"""
img = Image.open(img_path)
# Use first frame for animated GIFs
if hasattr(img, "n_frames") and img.n_frames > 1:
img.seek(0)
img = img.convert("RGBA")
# Resize maintaining aspect ratio, height rounded to even for half-blocks
ratio = img.height / img.width
pixel_height = int(width * ratio)
pixel_height += pixel_height % 2 # ensure even
img = img.resize((width, pixel_height), Image.LANCZOS)
lines = []
for y in range(0, pixel_height, 2):
chunks = []
prev_fg = None
prev_bg = None
for x in range(width):
tr, tg, tb, ta = img.getpixel((x, y))
if y + 1 < pixel_height:
br, bg, bb, ba = img.getpixel((x, y + 1))
else:
br, bg, bb, ba = 0, 0, 0, 0
# Treat near-transparent pixels as black
if ta < 128:
tr, tg, tb = 0, 0, 0
if ba < 128:
br, bg, bb = 0, 0, 0
fg = (tr, tg, tb)
bk = (br, bg, bb)
# Only emit escape codes when color changes
codes = []
if fg != prev_fg:
codes.append(f"\033[38;2;{fg[0]};{fg[1]};{fg[2]}m")
prev_fg = fg
if bk != prev_bg:
codes.append(f"\033[48;2;{bk[0]};{bk[1]};{bk[2]}m")
prev_bg = bk
chunks.append("".join(codes) + "\u2580")
lines.append("".join(chunks) + "\033[0m")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Pre-bake splash art as ANSI half-block text")
parser.add_argument("--width", type=int, default=80, help="Output width in columns (default: 80)")
args = parser.parse_args()
if not ASSETS_DIR.exists():
print(f"Assets directory not found: {ASSETS_DIR}")
return
for filename in SOURCE_FILES:
src = ASSETS_DIR / filename
if not src.exists():
print(f" SKIP {filename} (not found)")
continue
stem = src.stem
dst = ASSETS_DIR / f"{stem}.ans"
print(f" BAKE {filename} -> {stem}.ans ({args.width} cols) ...", end=" ", flush=True)
ansi_text = image_to_ansi(src, width=args.width)
dst.write_text(ansi_text)
# Stats
src_kb = src.stat().st_size / 1024
dst_kb = dst.stat().st_size / 1024
line_count = ansi_text.count("\n") + 1
print(f"{src_kb:.1f}KB -> {dst_kb:.1f}KB ({line_count} lines)")
print("\nDone. Pre-baked .ans files are ready for instant splash display.")
if __name__ == "__main__":
main()