diff --git a/tui/pyproject.toml b/tui/pyproject.toml index bd72fb8..db220c0 100644 --- a/tui/pyproject.toml +++ b/tui/pyproject.toml @@ -18,6 +18,20 @@ skywalker-tui = "skywalker_tui.app:main" [tool.hatch.build.targets.wheel] packages = ["src/skywalker_tui"] +artifacts = ["src/skywalker_tui/assets/**"] + +[project.optional-dependencies] +dev = [ + "Pillow>=10.0", +] +test = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" [tool.ruff] target-version = "py311" diff --git a/tui/scripts/fetch_art.py b/tui/scripts/fetch_art.py new file mode 100644 index 0000000..f144244 --- /dev/null +++ b/tui/scripts/fetch_art.py @@ -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() diff --git a/tui/scripts/prebake_splash.py b/tui/scripts/prebake_splash.py new file mode 100644 index 0000000..1217194 --- /dev/null +++ b/tui/scripts/prebake_splash.py @@ -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() diff --git a/tui/src/skywalker_tui/__init__.py b/tui/src/skywalker_tui/__init__.py index 83dd68a..11f52c0 100644 --- a/tui/src/skywalker_tui/__init__.py +++ b/tui/src/skywalker_tui/__init__.py @@ -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)) diff --git a/tui/src/skywalker_tui/app.py b/tui/src/skywalker_tui/app.py index b5da6d1..74d9c45 100644 --- a/tui/src/skywalker_tui/app.py +++ b/tui/src/skywalker_tui/app.py @@ -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: diff --git a/tui/src/skywalker_tui/assets/__init__.py b/tui/src/skywalker_tui/assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/src/skywalker_tui/assets/splash/CREDITS.md b/tui/src/skywalker_tui/assets/splash/CREDITS.md new file mode 100644 index 0000000..dc89688 --- /dev/null +++ b/tui/src/skywalker_tui/assets/splash/CREDITS.md @@ -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) | diff --git a/tui/src/skywalker_tui/assets/splash/dialtone.ans b/tui/src/skywalker_tui/assets/splash/dialtone.ans new file mode 100644 index 0000000..f1b9aae --- /dev/null +++ b/tui/src/skywalker_tui/assets/splash/dialtone.ans @@ -0,0 +1,27 @@ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \ No newline at end of file diff --git a/tui/src/skywalker_tui/assets/splash/dialtone.jpg b/tui/src/skywalker_tui/assets/splash/dialtone.jpg new file mode 100644 index 0000000..5f0d851 Binary files /dev/null and b/tui/src/skywalker_tui/assets/splash/dialtone.jpg differ diff --git a/tui/src/skywalker_tui/assets/splash/prodigy-out-of-space.ans b/tui/src/skywalker_tui/assets/splash/prodigy-out-of-space.ans new file mode 100644 index 0000000..f85da17 --- /dev/null +++ b/tui/src/skywalker_tui/assets/splash/prodigy-out-of-space.ans @@ -0,0 +1,40 @@ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \ No newline at end of file diff --git a/tui/src/skywalker_tui/assets/splash/prodigy-out-of-space.jpg b/tui/src/skywalker_tui/assets/splash/prodigy-out-of-space.jpg new file mode 100644 index 0000000..39366b3 Binary files /dev/null and b/tui/src/skywalker_tui/assets/splash/prodigy-out-of-space.jpg differ diff --git a/tui/src/skywalker_tui/assets/splash/seti-satellite.ans b/tui/src/skywalker_tui/assets/splash/seti-satellite.ans new file mode 100644 index 0000000..753c266 --- /dev/null +++ b/tui/src/skywalker_tui/assets/splash/seti-satellite.ans @@ -0,0 +1,32 @@ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \ No newline at end of file diff --git a/tui/src/skywalker_tui/assets/splash/seti-satellite.png b/tui/src/skywalker_tui/assets/splash/seti-satellite.png new file mode 100644 index 0000000..08461c4 Binary files /dev/null and b/tui/src/skywalker_tui/assets/splash/seti-satellite.png differ diff --git a/tui/src/skywalker_tui/assets/splash/so-far-away.ans b/tui/src/skywalker_tui/assets/splash/so-far-away.ans new file mode 100644 index 0000000..d830ca9 --- /dev/null +++ b/tui/src/skywalker_tui/assets/splash/so-far-away.ans @@ -0,0 +1,32 @@ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \ No newline at end of file diff --git a/tui/src/skywalker_tui/assets/splash/so-far-away.gif b/tui/src/skywalker_tui/assets/splash/so-far-away.gif new file mode 100644 index 0000000..bb70b98 Binary files /dev/null and b/tui/src/skywalker_tui/assets/splash/so-far-away.gif differ diff --git a/tui/src/skywalker_tui/assets/splash/space-docker.ans b/tui/src/skywalker_tui/assets/splash/space-docker.ans new file mode 100644 index 0000000..f7fbb4f --- /dev/null +++ b/tui/src/skywalker_tui/assets/splash/space-docker.ans @@ -0,0 +1,32 @@ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \ No newline at end of file diff --git a/tui/src/skywalker_tui/assets/splash/space-docker.gif b/tui/src/skywalker_tui/assets/splash/space-docker.gif new file mode 100644 index 0000000..988ff0d Binary files /dev/null and b/tui/src/skywalker_tui/assets/splash/space-docker.gif differ diff --git a/tui/src/skywalker_tui/screens/lband.py b/tui/src/skywalker_tui/screens/lband.py index 5a91a65..22d9ba4 100644 --- a/tui/src/skywalker_tui/screens/lband.py +++ b/tui/src/skywalker_tui/screens/lband.py @@ -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 diff --git a/tui/src/skywalker_tui/screens/monitor.py b/tui/src/skywalker_tui/screens/monitor.py index 61515cf..c4814c4 100644 --- a/tui/src/skywalker_tui/screens/monitor.py +++ b/tui/src/skywalker_tui/screens/monitor.py @@ -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 diff --git a/tui/src/skywalker_tui/screens/scan.py b/tui/src/skywalker_tui/screens/scan.py index aea998d..ad7eb0b 100644 --- a/tui/src/skywalker_tui/screens/scan.py +++ b/tui/src/skywalker_tui/screens/scan.py @@ -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 diff --git a/tui/src/skywalker_tui/screens/spectrum.py b/tui/src/skywalker_tui/screens/spectrum.py index 4c171ad..74a5c80 100644 --- a/tui/src/skywalker_tui/screens/spectrum.py +++ b/tui/src/skywalker_tui/screens/spectrum.py @@ -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 (950–2150 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.1–500 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): diff --git a/tui/src/skywalker_tui/screens/splash.py b/tui/src/skywalker_tui/screens/splash.py new file mode 100644 index 0000000..5a19ce2 --- /dev/null +++ b/tui/src/skywalker_tui/screens/splash.py @@ -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() diff --git a/tui/src/skywalker_tui/screens/starwars.py b/tui/src/skywalker_tui/screens/starwars.py new file mode 100644 index 0000000..9429e42 --- /dev/null +++ b/tui/src/skywalker_tui/screens/starwars.py @@ -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) diff --git a/tui/src/skywalker_tui/screens/track.py b/tui/src/skywalker_tui/screens/track.py index 9e4852a..e0055c2 100644 --- a/tui/src/skywalker_tui/screens/track.py +++ b/tui/src/skywalker_tui/screens/track.py @@ -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 (950–2150 MHz)[/]") + return + if not (256 <= sr <= 30000): + log.write("[bold #e04040]Symbol rate out of range (256–30000 ksps)[/]") + return + if not (0.1 <= rate <= 100): + log.write("[bold #e04040]Rate out of range (0.1–100 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}[/]") diff --git a/tui/src/skywalker_tui/theme.tcss b/tui/src/skywalker_tui/theme.tcss index 5919970..10916d0 100644 --- a/tui/src/skywalker_tui/theme.tcss +++ b/tui/src/skywalker_tui/theme.tcss @@ -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; +} diff --git a/tui/src/skywalker_tui/widgets/radar_scope.py b/tui/src/skywalker_tui/widgets/radar_scope.py new file mode 100644 index 0000000..bdae576 --- /dev/null +++ b/tui/src/skywalker_tui/widgets/radar_scope.py @@ -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 diff --git a/tui/src/skywalker_tui/widgets/signal_gauge.py b/tui/src/skywalker_tui/widgets/signal_gauge.py index 26e3630..80b6c33 100644 --- a/tui/src/skywalker_tui/widgets/signal_gauge.py +++ b/tui/src/skywalker_tui/widgets/signal_gauge.py @@ -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 diff --git a/tui/src/skywalker_tui/widgets/status_bar.py b/tui/src/skywalker_tui/widgets/status_bar.py index a771eae..edc7c9d 100644 --- a/tui/src/skywalker_tui/widgets/status_bar.py +++ b/tui/src/skywalker_tui/widgets/status_bar.py @@ -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 diff --git a/tui/tests/__init__.py b/tui/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/tests/test_radar_scope.py b/tui/tests/test_radar_scope.py new file mode 100644 index 0000000..5a5be1b --- /dev/null +++ b/tui/tests/test_radar_scope.py @@ -0,0 +1,192 @@ +"""Tests for the radar scope widget — LUT geometry and pixel intensity.""" + +from skywalker_tui.widgets.radar_scope import RadarScope + + +class TestRadarGeometry: + """LUT pre-computation and geometry tests.""" + + def _make_scope(self, width=40, height=20): + scope = RadarScope() + scope._recompute_geometry(width, height) + return scope + + def test_lut_dimensions(self): + scope = self._make_scope(40, 20) + # pixel rows = terminal rows * 2 + assert len(scope._dist_lut) == 40 + assert len(scope._angle_lut) == 40 + assert len(scope._dx_lut) == 40 + assert len(scope._dy_lut) == 40 + # each row has `width` columns + assert len(scope._dist_lut[0]) == 40 + assert len(scope._angle_lut[0]) == 40 + + def test_center_pixel_distance_near_zero(self): + scope = self._make_scope(40, 20) + # center = (20, 20) in pixel coords + cx_int = int(scope._cx) + cy_int = int(scope._cy) + dist = scope._dist_lut[cy_int][cx_int] + assert dist < 1.0, f"Center pixel distance should be ~0, got {dist}" + + def test_corner_pixel_outside_circle(self): + scope = self._make_scope(40, 20) + dist = scope._dist_lut[0][0] + assert dist > scope._radius, "Corner should be outside the radar circle" + + def test_radius_fits_within_bounds(self): + scope = self._make_scope(60, 30) + # radius should be ≤ half the smaller dimension + assert scope._radius <= 30 # half of width + assert scope._radius <= 29 # half of pixel height (60) - 1 + + def test_recompute_updates_on_resize(self): + scope = self._make_scope(40, 20) + r1 = scope._radius + scope._recompute_geometry(80, 40) + r2 = scope._radius + assert r2 > r1, "Larger terminal should give larger radius" + + +class TestPixelIntensity: + """Tests for _pixel_intensity with pre-computed LUT values.""" + + def _make_scope(self, width=40, height=20): + scope = RadarScope() + scope._recompute_geometry(width, height) + return scope + + def test_outside_circle_returns_zero(self): + scope = self._make_scope() + samples = [0.0] * 360 + result = scope._pixel_intensity( + dist=scope._radius + 5, + sample_idx=0, dx=20.0, dy=0.0, + radius=scope._radius, samples=samples, + angle_idx=0, locked=False, + ) + assert result == 0 + + def test_center_on_crosshair(self): + """At exact center, crosshair check (abs(dx) < 0.7) fires before center dot.""" + scope = self._make_scope() + samples = [0.0] * 360 + result = scope._pixel_intensity( + dist=0.5, sample_idx=0, dx=0.0, dy=0.0, + radius=scope._radius, samples=samples, + angle_idx=0, locked=False, + ) + assert result == 2 # crosshair overlaps center + + def test_center_off_crosshair(self): + """Near center but off crosshair axes → center dot (1).""" + scope = self._make_scope() + samples = [0.0] * 360 + result = scope._pixel_intensity( + dist=0.8, sample_idx=45, dx=0.8, dy=0.8, + radius=scope._radius, samples=samples, + angle_idx=0, locked=False, + ) + assert result == 1 # center dot, off crosshair axes + + def test_lock_ring_at_edge(self): + scope = self._make_scope() + samples = [0.0] * 360 + result = scope._pixel_intensity( + dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0, + radius=scope._radius, samples=samples, + angle_idx=0, locked=True, + ) + assert result == 7 # peak phosphor for lock ring + + def test_no_lock_ring_when_unlocked(self): + scope = self._make_scope() + samples = [0.0] * 360 + result = scope._pixel_intensity( + dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0, + radius=scope._radius, samples=samples, + angle_idx=0, locked=False, + ) + # Should be boundary ring (2), not lock ring (7) + assert result != 7 + + def test_crosshair_on_vertical(self): + scope = self._make_scope() + samples = [0.0] * 360 + # Point on the vertical crosshair (dx near 0, inside circle) + result = scope._pixel_intensity( + dist=scope._radius * 0.5, + sample_idx=90, dx=0.0, dy=scope._radius * 0.5, + radius=scope._radius, samples=samples, + angle_idx=0, locked=False, + ) + assert result == 2 # crosshair intensity + + def test_strong_signal_blip_near_sweep(self): + scope = self._make_scope() + samples = [0.0] * 360 + # strength 0.47 → blip at 0.47 * r * 0.85 = 0.40*r + # Clear of range rings at 0.25r, 0.50r, 0.75r (nearest gap: 0.15r ≈ 2.85px) + samples[180] = 0.47 + signal_dist = 0.47 * scope._radius * 0.85 + # Verify we're not on a range ring + r = scope._radius + for ring_r in (0.25, 0.5, 0.75): + assert abs(signal_dist - r * ring_r) > 0.7, ( + f"Signal blip at {signal_dist:.2f} overlaps range ring at {r * ring_r:.2f}" + ) + result = scope._pixel_intensity( + dist=signal_dist, + sample_idx=180, dx=5.0, dy=5.0, + radius=scope._radius, samples=samples, + angle_idx=180, locked=False, + ) + # Newest sample at sweep position should be visible + assert result >= 3 + + def test_old_signal_decays(self): + scope = self._make_scope() + samples = [0.0] * 360 + samples[0] = 0.8 + signal_dist = 0.8 * scope._radius * 0.85 + # Sample at index 0, sweep beam at index 300 → age = 300 + result = scope._pixel_intensity( + dist=signal_dist, + sample_idx=0, dx=5.0, dy=5.0, + radius=scope._radius, samples=samples, + angle_idx=300, locked=False, + ) + # Old sample should be dim or invisible + assert result <= 2 + + +class TestRadarPush: + """Tests for the push() method and normalization.""" + + def test_push_normalizes_to_range(self): + scope = RadarScope(max_samples=10) + scope.push(8.0, max_snr=16.0) + assert scope._samples[-1] == 0.5 + + def test_push_clamps_above_max(self): + scope = RadarScope(max_samples=10) + scope.push(20.0, max_snr=16.0) + assert scope._samples[-1] == 1.0 + + def test_push_clamps_below_zero(self): + scope = RadarScope(max_samples=10) + scope.push(-5.0, max_snr=16.0) + assert scope._samples[-1] == 0.0 + + def test_angle_index_wraps(self): + scope = RadarScope(max_samples=4) + for _ in range(5): + scope.push(1.0) + assert scope._angle_idx == 1 # 5 % 4 = 1 + + def test_set_locked(self): + scope = RadarScope() + assert scope._locked is False + scope.set_locked(True) + assert scope._locked is True diff --git a/tui/tests/test_splash.py b/tui/tests/test_splash.py new file mode 100644 index 0000000..9cfe4c0 --- /dev/null +++ b/tui/tests/test_splash.py @@ -0,0 +1,48 @@ +"""Tests for the splash screen art catalog and selection.""" + +from skywalker_tui.screens.splash import SplashScreen, ASSETS_DIR, ART_CATALOG + + +class TestSplashArtCatalog: + """Verify bundled pre-baked .ans art assets exist and catalog is consistent.""" + + def test_assets_dir_exists(self): + assert ASSETS_DIR.is_dir(), f"Assets dir missing: {ASSETS_DIR}" + + def test_all_catalog_entries_have_ans_files(self): + missing = [] + for stem, artist, title in ART_CATALOG: + path = ASSETS_DIR / f"{stem}.ans" + if not path.exists(): + missing.append(f"{stem}.ans") + assert not missing, f"Missing pre-baked .ans files: {missing}" + + def test_catalog_has_entries(self): + assert len(ART_CATALOG) >= 1 + + def test_ans_files_not_empty(self): + for stem, _, _ in ART_CATALOG: + path = ASSETS_DIR / f"{stem}.ans" + if path.exists(): + assert path.stat().st_size > 0, f"{stem}.ans is empty" + + def test_ans_files_contain_ansi_escapes(self): + """Pre-baked files should contain ANSI color escape sequences.""" + for stem, _, _ in ART_CATALOG: + path = ASSETS_DIR / f"{stem}.ans" + if path.exists(): + content = path.read_text() + assert "\033[" in content, f"{stem}.ans has no ANSI escapes" + assert "\u2580" in content, f"{stem}.ans has no half-block chars" + + +class TestSplashScreenInit: + """Test SplashScreen construction (no app needed).""" + + def test_selects_art_on_init(self): + screen = SplashScreen() + assert screen._ans_path is not None or len(ART_CATALOG) == 0 + if screen._ans_path: + assert screen._ans_path.exists() + assert screen._artist != "" + assert screen._title != "" diff --git a/tui/tests/test_telnet_stripper.py b/tui/tests/test_telnet_stripper.py new file mode 100644 index 0000000..94582f2 --- /dev/null +++ b/tui/tests/test_telnet_stripper.py @@ -0,0 +1,108 @@ +"""Tests for the stateful telnet IAC sequence stripper.""" + +from skywalker_tui.screens.starwars import _TelnetStripper + + +class TestTelnetStripper: + """Edge cases for IAC parsing across chunk boundaries.""" + + def test_plain_text_passthrough(self): + s = _TelnetStripper() + assert s.feed(b"hello world") == b"hello world" + + def test_strips_will_command(self): + # IAC WILL ECHO = FF FB 01 + s = _TelnetStripper() + assert s.feed(b"\xff\xfb\x01hello") == b"hello" + + def test_strips_wont_command(self): + s = _TelnetStripper() + assert s.feed(b"\xff\xfc\x03data") == b"data" + + def test_strips_do_command(self): + s = _TelnetStripper() + assert s.feed(b"\xff\xfd\x01data") == b"data" + + def test_strips_dont_command(self): + s = _TelnetStripper() + assert s.feed(b"\xff\xfe\x01data") == b"data" + + def test_escaped_0xff(self): + """Doubled 0xFF = literal 0xFF byte in content.""" + s = _TelnetStripper() + assert s.feed(b"\xff\xff") == b"\xff" + + def test_sub_negotiation(self): + # IAC SB 0x01 ... IAC SE = FF FA 01 xx xx FF F0 + s = _TelnetStripper() + data = b"before\xff\xfa\x01\x00\x00\xff\xf0after" + assert s.feed(data) == b"beforeafter" + + def test_split_iac_across_chunks(self): + """IAC command split: FF in chunk 1, FB 01 in chunk 2.""" + s = _TelnetStripper() + out1 = s.feed(b"hello\xff") + out2 = s.feed(b"\xfb\x01world") + assert out1 == b"hello" + assert out2 == b"world" + + def test_split_will_at_boundary(self): + """IAC WILL split: FF FB in chunk 1, option byte in chunk 2.""" + s = _TelnetStripper() + out1 = s.feed(b"aaa\xff\xfb") + out2 = s.feed(b"\x03bbb") + assert out1 == b"aaa" + assert out2 == b"bbb" + + def test_split_sub_negotiation(self): + """Sub-negotiation without closing IAC SE — buffers until next chunk.""" + s = _TelnetStripper() + out1 = s.feed(b"x\xff\xfa\x01\x00") + out2 = s.feed(b"\xff\xf0y") + assert out1 == b"x" + assert out2 == b"y" + + def test_multiple_commands_in_one_chunk(self): + s = _TelnetStripper() + data = b"\xff\xfb\x01\xff\xfc\x03text\xff\xfd\x01" + assert s.feed(data) == b"text" + + def test_empty_input(self): + s = _TelnetStripper() + assert s.feed(b"") == b"" + + def test_just_iac_byte(self): + """Single 0xFF byte — should buffer, waiting for next byte.""" + s = _TelnetStripper() + out1 = s.feed(b"\xff") + assert out1 == b"" + out2 = s.feed(b"\xfb\x01done") + assert out2 == b"done" + + def test_unknown_iac_command(self): + """Unknown command byte after IAC — stripped as 2-byte sequence.""" + s = _TelnetStripper() + assert s.feed(b"\xff\xf1text") == b"text" + + +class TestFrameParsing: + """Tests for the ESC[H frame boundary detection logic.""" + + def test_frame_split_on_cursor_home(self): + """ESC[H splits data into frames.""" + data = b"frame1\x1b[Hframe2\x1b[Hframe3" + frames = data.split(b"\x1b[H") + assert frames == [b"frame1", b"frame2", b"frame3"] + + def test_esc_j_in_frame(self): + """ESC[J (clear to end) can be stripped from frame data.""" + data = b"\x1b[Jsome text here" + clean = data.replace(b"\x1b[J", b"") + assert clean == b"some text here" + + def test_empty_frames_between_homes(self): + """Consecutive ESC[H produces empty frames (filtered in code).""" + data = b"\x1b[H\x1b[Htext" + frames = data.split(b"\x1b[H") + non_empty = [f for f in frames if f.strip()] + assert non_empty == [b"text"] diff --git a/tui/uv.lock b/tui/uv.lock index 15101ac..1bedef6 100644 --- a/tui/uv.lock +++ b/tui/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -52,6 +70,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + [[package]] name = "platformdirs" version = "4.7.0" @@ -61,6 +175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983, upload-time = "2026-02-12T22:21:52.237Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -70,6 +193,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pyusb" version = "1.3.1" @@ -101,11 +253,24 @@ dependencies = [ { name = "textual" }, ] +[package.optional-dependencies] +dev = [ + { name = "pillow" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ + { name = "pillow", marker = "extra == 'dev'", specifier = ">=10.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24" }, { name = "pyusb", specifier = ">=1.3" }, { name = "textual", specifier = ">=3.0" }, ] +provides-extras = ["dev", "test"] [[package]] name = "textual"