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

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

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

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

View File

@ -18,6 +18,20 @@ skywalker-tui = "skywalker_tui.app:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/skywalker_tui"] 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] [tool.ruff]
target-version = "py311" target-version = "py311"

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

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

View File

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

View File

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

View File

@ -9,13 +9,6 @@ with Textual's built-in App.mode / _current_mode / _screen_stacks system.
import argparse import argparse
import sys 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.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
@ -57,14 +50,17 @@ class SkyWalkerApp(App):
Binding("f5", "rf_mode('track')", "Track", show=True), Binding("f5", "rf_mode('track')", "Track", show=True),
Binding("q", "quit", "Quit", show=True), Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", 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__() super().__init__()
self._bridge = bridge self._bridge = bridge
self._initial_rf_mode = initial_mode self._initial_rf_mode = initial_mode
self._active_rf_mode = initial_mode self._active_rf_mode = initial_mode
self._rf_screens: dict[str, object] = {} self._rf_screens: dict[str, object] = {}
self._show_splash = show_splash
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@ -81,18 +77,35 @@ class SkyWalkerApp(App):
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
# Initialize status bar # Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar) status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge) 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) switcher = self.query_one("#content-area", ContentSwitcher)
for mode_key, (_label, cls) in MODES.items(): for mode_key, (_label, cls) in MODES.items():
screen = cls(self._bridge, id=f"screen-{mode_key}") screen = cls(self._bridge, id=f"screen-{mode_key}")
self._rf_screens[mode_key] = screen self._rf_screens[mode_key] = screen
switcher.mount(screen) switcher.mount(screen)
# Activate initial mode
self.action_rf_mode(self._initial_rf_mode) self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None: def action_rf_mode(self, mode: str) -> None:
@ -110,7 +123,7 @@ class SkyWalkerApp(App):
btn.remove_class("-active") btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_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: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle sidebar mode button clicks.""" """Handle sidebar mode button clicks."""
@ -122,6 +135,25 @@ class SkyWalkerApp(App):
def action_toggle_dark(self) -> None: def action_toggle_dark(self) -> None:
self.dark = not self.dark 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(): def main():
@ -133,6 +165,10 @@ def main():
"--demo", action="store_true", "--demo", action="store_true",
help="Use synthetic signal data (no hardware required)", 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( parser.add_argument(
"mode", nargs="?", default="spectrum", "mode", nargs="?", default="spectrum",
choices=list(MODES.keys()), choices=list(MODES.keys()),
@ -158,7 +194,11 @@ def main():
print("Use --demo for synthetic signal data.", file=sys.stderr) print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1) 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: try:
app.run() app.run()
finally: finally:

View File

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -4,14 +4,9 @@ Same sweep mechanics as the spectrum screen, but with LNB disabled (direct input
and band allocation overlays showing what service each frequency range belongs to. 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.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical 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 import work
from textual.worker import Worker from textual.worker import Worker

View File

@ -9,7 +9,7 @@ from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static from textual.widgets import Label, Input, Button, Static
from textual import work 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.signal_gauge import SignalGauge
from skywalker_tui.widgets.sparkline_widget import SparklineWidget from skywalker_tui.widgets.sparkline_widget import SparklineWidget

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,6 @@
"""Device status bar — connection state, firmware version, config bits.""" """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.app import ComposeResult
from textual.containers import Horizontal
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Label from textual.widgets import Label

0
tui/tests/__init__.py Normal file
View File

View File

@ -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

48
tui/tests/test_splash.py Normal file
View File

@ -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 != ""

View File

@ -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"]

165
tui/uv.lock generated
View File

@ -2,6 +2,24 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.11" 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]] [[package]]
name = "linkify-it-py" name = "linkify-it-py"
version = "2.0.3" 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" }, { 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]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.7.0" 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" }, { 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" 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" }, { 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]] [[package]]
name = "pyusb" name = "pyusb"
version = "1.3.1" version = "1.3.1"
@ -101,11 +253,24 @@ dependencies = [
{ name = "textual" }, { name = "textual" },
] ]
[package.optional-dependencies]
dev = [
{ name = "pillow" },
]
test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata] [package.metadata]
requires-dist = [ 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 = "pyusb", specifier = ">=1.3" },
{ name = "textual", specifier = ">=3.0" }, { name = "textual", specifier = ">=3.0" },
] ]
provides-extras = ["dev", "test"]
[[package]] [[package]]
name = "textual" name = "textual"