diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..dc1e6d6 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,580 @@ +# Deployment Guide + +Complete guide to running and building Twitch Drops Miner across every platform and frontend. + +## Table of Contents + +- [Frontend Overview](#frontend-overview) +- [Running from Source](#running-from-source) + - [Windows](#windows-source) + - [Linux](#linux-source) + - [macOS](#macos-source) + - [Termux / Android](#termux--android-source) +- [Pre-built Releases](#pre-built-releases) +- [Building from Source](#building-from-source) + - [Building the GUI](#building-the-gui) + - [Building the TUI / CLI Binary](#building-the-tui--cli-binary) + - [Building the Linux AppImage](#building-the-linux-appimage) +- [Platform Reference Matrix](#platform-reference-matrix) +- [Persistent Data Locations](#persistent-data-locations) +- [Troubleshooting](#troubleshooting) + +--- + +## Frontend Overview + +The project has three frontends: + +| Frontend | Entry Point | Dependencies | Platforms | +|----------|------------|--------------|-----------| +| **GUI** | `main.py` | `requirements.txt` (tkinter, pystray, Pillow) | Windows, Linux, macOS | +| **TUI** | `tdminer.py` (with `tui` arg) | `requirements-tui.txt` (textual, rich, prompt_toolkit) | Linux, macOS | +| **CLI** | `tdminer.py` (with `cli` arg) | `requirements-tui.txt` (rich, prompt_toolkit) | Windows, Linux, macOS, Termux | + +- **GUI** — Full graphical interface with tkinter, system tray, and progress bars. +- **TUI** — Rich terminal UI using Textual (full-screen, mouse support, tabs). Only on Linux/macOS. +- **CLI** — Portable prompt_toolkit-based terminal with Rich styling, slash-command autocomplete. Works everywhere including Windows and Termux. + +The `tdminer` entry point auto-selects: on Windows it defaults to CLI, on Linux/macOS it defaults to TUI. You can force either with `tdminer cli` or `tdminer tui`. + +--- + +## Running from Source + +**Requires:** Python 3.10+ + +### Windows Source + +#### GUI (from source) + +```powershell +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python main.py +``` + +Or use the provided batch scripts: + +```cmd +setup_env.bat +run_dev.bat +``` + +#### TUI / CLI (from source) + +```powershell +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements-tui.txt + +# Auto-select (defaults to CLI on Windows) +python tdminer.py + +# Force CLI +python tdminer.py cli + +# Force TUI (not supported on Windows) +python tdminer.py tui +``` + +**Note:** The Textual TUI (`tui` mode) is not supported on Windows. Use `cli` mode instead. + +### Linux Source + +#### GUI (from source) + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +# Install system dependencies (Debian/Ubuntu) +sudo apt install python3-tk gir1.2-ayatanaappindicator3-0.1 + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +For Fedora/RHEL: + +```bash +sudo dnf install python3-tkinter python3-gobject ayatana-appindicator3-gtk3 +``` + +For Arch: + +```bash +sudo pacman -S tk libayatana-appindicator +``` + +#### TUI / CLI (from source) + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements-tui.txt + +# Auto-select (defaults to TUI on Linux) +python tdminer.py + +# Force TUI +python tdminer.py tui + +# Force CLI +python tdminer.py cli +``` + +### macOS Source + +#### GUI (from source) + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +#### TUI / CLI (from source) + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements-tui.txt + +# Auto-select (defaults to TUI on macOS) +python tdminer.py + +# Force TUI +python tdminer.py tui + +# Force CLI +python tdminer.py cli +``` + +### Termux / Android Source + +Termux cannot run pre-built Linux binaries because Android uses Bionic libc, not glibc. You must install from source. + +#### CLI (only supported frontend on Termux) + +```bash +# Install prerequisites +pkg install python clang curl tar + +# One-line install (recommended) +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh + +# Or manual install +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements-tui.txt +python tdminer.py cli +``` + +The install script creates a `tdminer` launcher in `$PREFIX/bin` (usually `/data/data/com.termux/files/usr/bin`). After installation, just run: + +```bash +tdminer +``` + +**Termux tips:** + +- Use `tmux` or `screen` to keep sessions alive in the background. +- The CLI frontend is designed for Termux and works well with its terminal capabilities. +- Data files (cookies, settings, cache) are stored in `~/.local/share/tdminer/data/`. + +--- + +## Pre-built Releases + +Download from: https://github.com/HimanM/TwitchDropsMiner/releases + +### Available Release Assets + +| Asset | Platform | Frontend | Format | +|-------|----------|----------|--------| +| `Twitch.Drops.Miner.Windows.zip` | Windows x86_64 | GUI | Single .exe | +| `Twitch.Drops.Miner.MacOS.zip` | macOS universal | GUI | .app bundle | +| `Twitch.Drops.Miner.Linux.AppImage-x86_64.zip` | Linux x86_64 | GUI | AppImage | +| `Twitch.Drops.Miner.Linux.AppImage-aarch64.zip` | Linux ARM64 | GUI | AppImage | +| `Twitch.Drops.Miner.Linux.PyInstaller-x86_64.zip` | Linux x86_64 | GUI | Single binary | +| `Twitch.Drops.Miner.Linux.PyInstaller-aarch64.zip` | Linux ARM64 | GUI | Single binary | +| `Twitch.Drops.Miner.TUI.Linux-x86_64.zip` | Linux x86_64 | TUI/CLI | Single binary | +| `Twitch.Drops.Miner.TUI.Linux-aarch64.zip` | Linux ARM64 | TUI/CLI | Single binary | +| `Twitch.Drops.Miner.TUI.MacOS.zip` | macOS | TUI/CLI | Single binary | + +### Quick Install (Linux/macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh +``` + +Custom install directory: + +```bash +TDMINER_INSTALL_DIR="$HOME/bin" curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh +``` + +After installation: + +```bash +tdminer # auto-select frontend +tdminer tui # Textual TUI (Linux/macOS) +tdminer cli # portable CLI (everywhere) +``` + +### Quick Install (Termux) + +```bash +pkg install python clang curl tar +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh +tdminer +``` + +--- + +## Building from Source + +### Prerequisites + +| Tool | Purpose | Install | +|------|---------|---------| +| Python 3.10+ | Runtime | https://python.org | +| pip | Package manager | Included with Python | +| PyInstaller | Binary packaging | `pip install pyinstaller` | +| git | Source checkout | https://git-scm.com | +| 7z (Windows) | Release packaging | Optional, for `pack.bat` | + +### Building the GUI + +The GUI build packages `main.py` with tkinter, pystray, and Pillow into a standalone executable. + +#### Windows GUI Build + +```powershell +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +# Set up environment (installs requirements.txt) +setup_env.bat + +# Build +build.bat + +# Or manually: +py -m venv build-env +.\build-env\Scripts\Activate.ps1 +pip install -r requirements.txt pyinstaller +pyinstaller build.spec + +# Package the release +pack.bat +``` + +Output: `dist/Twitch Drops Miner (by HimanM).exe` + +The `build.bat` script: +1. Creates/uses the `env/` virtual environment +2. Installs PyInstaller if missing +3. Runs `pyinstaller build.spec` +4. Output goes to `dist/` + +#### Linux GUI Build (PyInstaller) + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +# Install system dependencies (Debian/Ubuntu) +sudo apt install python3-tk gir1.2-ayatanaappindicator3-0.1 \ + libgirepository1.0-dev libayatana-appindicator3-1 + +# Set up environment +python3 -m venv env +source env/bin/activate +pip install -r requirements.txt pyinstaller + +# On headless systems, use xvfb for the build +xvfb-run --auto-servernum pyinstaller build.spec + +# If libXft is old (< 2.3.5), build a newer version first: +mkdir -p /tmp/libXft && cd /tmp/libXft +curl -fL https://xorg.freedesktop.org/releases/individual/lib/libXft-2.3.9.tar.xz -o libXft.tar.xz +tar xvf libXft.tar.xz && cd libXft-* +./configure --prefix=/tmp/libXft --disable-static +make -j$(nproc) && make install-strip +cd - + +LD_LIBRARY_PATH=/tmp/libXft/lib xvfb-run --auto-servernum pyinstaller build.spec +``` + +Output: `dist/Twitch Drops Miner (by HimanM)` (one-dir) or single executable + +#### Linux GUI Build (AppImage) + +```bash +# Install appimage-builder +pip install git+https://github.com/AppImageCrafters/appimage-builder.git@e995e8ed + +# Install system dependencies +sudo apt install gir1.2-ayatanaappindicator3-0.1 libayatana-appindicator3-1 + +# Build +ARCH=x86_64 APP_VERSION=1.0.0 PYTHON_VERSION=3.10 \ + appimage-builder --recipe appimage/AppImageBuilder.yml +``` + +Output: `Twitch.Drops.Miner-x86_64.AppImage` + +The AppImage bundles Python, tkinter, and all dependencies. It runs on most Linux distributions without installation. + +#### macOS GUI Build + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv env +source env/bin/activate +pip install -r requirements.txt pyinstaller + +pyinstaller build.spec --noconfirm +``` + +Output: `dist/Twitch Drops Miner (by HimanM).app` + +### Building the TUI / CLI Binary + +The TUI build packages `tdminer.py` with textual, rich, and prompt_toolkit into a single terminal binary. + +#### Linux TUI Build + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv env +source env/bin/activate +pip install -r requirements-tui.txt pyinstaller + +pyinstaller build_tui.spec --noconfirm +``` + +Output: `dist/tdminer` + +The binary works on Linux systems with compatible glibc. No Python installation needed on the target machine. + +#### macOS TUI Build + +```bash +git clone https://github.com/HimanM/TwitchDropsMiner.git +cd TwitchDropsMiner + +python3 -m venv env +source env/bin/activate +pip install -r requirements-tui.txt pyinstaller + +pyinstaller build_tui.spec --noconfirm +``` + +Output: `dist/tdminer` + +#### Windows TUI Build + +The TUI build is not officially supported on Windows via PyInstaller, but the CLI mode runs directly from source: + +```powershell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements-tui.txt +python tdminer.py cli +``` + +#### Termux Build + +Termux cannot use PyInstaller to create native binaries. Use the source install method described in [Termux / Android Source](#termux--android-source). + +### CI / GitHub Actions + +The project includes a GitHub Actions workflow (`.github/workflows/ci.yml`) that automatically builds all targets on push to `main`: + +- **validate** — Runs TUI tests and compiles Python modules +- **windows** — Builds GUI exe for Windows +- **macos** — Builds GUI .app for macOS +- **macos-tui** — Builds TUI binary for macOS +- **linux-tui** — Builds TUI binary for Linux (x86_64 + aarch64) +- **linux-pyinstaller** — Builds GUI binary for Linux (x86_64 + aarch64) +- **linux-appimage** — Builds GUI AppImage for Linux (x86_64 + aarch64) +- **update_releases_page** — Uploads all artifacts to GitHub Releases + +To trigger a build manually, go to Actions > Build > Run workflow. + +--- + +## Platform Reference Matrix + +### Frontend Availability + +| Platform | GUI | TUI | CLI | +|----------|-----|-----|-----| +| Windows 10/11 | Yes | No | Yes | +| Ubuntu/Debian | Yes | Yes | Yes | +| Fedora/RHEL | Yes | Yes | Yes | +| Arch Linux | Yes | Yes | Yes | +| macOS | Yes | Yes | Yes | +| Termux (Android) | No | No | Yes | +| Docker/headless | No | No | Yes | +| SSH sessions | No | No | Yes | + +### Build Method by Platform + +| Platform | GUI Build | TUI Build | +|----------|-----------|-----------| +| Windows | PyInstaller (`build.spec`) | Source only | +| Linux x86_64 | PyInstaller or AppImage | PyInstaller (`build_tui.spec`) | +| Linux aarch64 | PyInstaller or AppImage | PyInstaller (`build_tui.spec`) | +| macOS | PyInstaller (`build.spec`) | PyInstaller (`build_tui.spec`) | +| Termux | Not available | Source install | + +### System Dependencies + +| Platform | GUI Dependencies | TUI/CLI Dependencies | +|----------|-----------------|---------------------| +| Windows | None (bundled) | None (bundled) | +| Ubuntu/Debian | `python3-tk`, `gir1.2-ayatanaappindicator3-0.1` | `python3` | +| Fedora | `python3-tkinter`, `python3-gobject`, `ayatana-appindicator3-gtk3` | `python3` | +| Arch | `tk`, `libayatana-appindicator` | `python` | +| macOS | None (bundled in .app) | None | +| Termux | Not available | `python`, `clang`, `curl`, `tar` | + +--- + +## Persistent Data Locations + +Data files are stored next to the executable by default, or in a dedicated data directory when using the installer. + +| File | Purpose | +|------|---------| +| `cookies.jar` | Twitch session cookies (keep private) | +| `settings.json` | User settings (priority, exclude, mode) | +| `cache/` | Cached images and API responses | +| `lock.file` | Prevents multiple instances | +| `logs/` | Log files (when `--log` is used) | + +### Installer Data Directory + +When using the install script, data is stored in: + +- **Linux/macOS:** `~/.local/share/tdminer/data/` +- **Termux:** `~/.local/share/tdminer/data/` + +The `TDMINER_DATA_DIR` environment variable can override this. + +### Portable Run + +When running from source or from a built executable, data files are stored in the current working directory (next to the executable). + +--- + +## Troubleshooting + +### "No python executable found" + +Ensure Python 3.10+ is installed and in your PATH: + +```bash +python3 --version # Should show 3.10+ +``` + +### "Failed to import tkinter" + +The GUI requires tkinter. Install it: + +```bash +# Ubuntu/Debian +sudo apt install python3-tk + +# Fedora +sudo dnf install python3-tkinter + +# Arch +sudo pacman -S tk +``` + +TUI and CLI do not require tkinter. + +### "Permission denied" when rebuilding + +The executable may be running. Close it before rebuilding. + +### Termux: "Permission denied" on tdminer + +```bash +chmod +x ~/.local/bin/tdminer +# Or for system-wide install: +chmod +x $PREFIX/bin/tdminer +``` + +### Termux: build errors with C extensions + +```bash +pkg install clang python +# Then retry the install +``` + +### Linux AppImage won't start + +Ensure FUSE is available: + +```bash +# Ubuntu/Debian +sudo apt install fuse libfuse2 + +# The AppImage may need to be marked executable +chmod +x *.AppImage +``` + +### GUI shows blank window on headless Linux + +The GUI requires a display server (X11 or Wayland). For headless builds, use `xvfb-run`. For running, use the TUI or CLI frontend instead. + +### "ModuleNotFoundError" when running from source + +Ensure you activated the virtual environment and installed the correct requirements: + +```bash +# For GUI +pip install -r requirements.txt + +# For TUI/CLI +pip install -r requirements-tui.txt +``` + +### TUI shows garbled output + +Ensure your terminal supports Unicode and ANSI colors. Most modern terminals work. For minimal terminals, use the CLI mode: + +```bash +tdminer cli +``` diff --git a/README.md b/README.md index 08defc6..404029d 100644 --- a/README.md +++ b/README.md @@ -86,35 +86,47 @@ The portable CLI has a boxed command input with slash-command autocomplete. Type `/` to list commands. Type `/priority add `, `/exclude add `, or `/switch ` to autocomplete available games and channels. -Common commands: - -```text -/dashboard -/channels -/channels next -/channels prev -/drops -/drops next -/drops prev -/settings -/logs -/reload -/switch -/priority add -/priority remove -/exclude add -/exclude remove -/mode priority-only -/mode ending-soonest -/mode low-availability -/farm-unlinked on -/farm-unlinked off -/filter expired on -/filter expired off -/filter finished on -/filter finished off -/quit -``` +Type `/help` in the CLI to view all commands grouped by category with descriptions. + +### Navigation + +| Command | Description | +|---------|-------------| +| `/dashboard` | Switch to the dashboard view (default) | +| `/channels` | Switch to the channels list view | +| `/channels next` | Page forward in the channels list | +| `/channels prev` | Page backward in the channels list | +| `/drops` | Switch to the drops/campaigns view | +| `/drops next` | Page forward in the drops list | +| `/drops prev` | Page backward in the drops list | +| `/settings` | Switch to the settings view | +| `/logs` | Switch to the logs view | +| `/help` | Show the help page (use `/help ` for details) | + +### Control + +| Command | Description | +|---------|-------------| +| `/reload` | Reload inventory and campaign data from Twitch | +| `/switch ` | Switch to a specific channel by name or ID | +| `/priority add ` | Add a game to the priority list | +| `/priority remove ` | Remove a game from the priority list | +| `/priority bump ` | Move a game up in the priority list | +| `/priority demote ` | Move a game down in the priority list | +| `/exclude add ` | Add a game to the exclude list | +| `/exclude remove ` | Remove a game from the exclude list | +| `/mode ` | Set priority mode: `priority-only`, `ending-soonest`, `low-availability` | +| `/filter ` | Toggle filters: `not-linked`, `upcoming`, `expired`, `excluded`, `finished` | +| `/farm-unlinked on\|off` | Enable/disable farming unlinked drops (priority-only mode) | + +### System + +| Command | Description | +|---------|-------------| +| `/open` | Open the Twitch login URL in a browser (when login is pending) | +| `/copy` | Show the Twitch login URL (when login is pending) | +| `/detach` | Detach from current tmux session (keeps miner running) | +| `/quit` | Exit the application | ## TUI Shortcuts @@ -165,6 +177,7 @@ python tdminer.py cli - `tdminer` release binaries are built with PyInstaller. - Linux release binaries require compatible glibc Linux systems. - Termux uses source install instead of release binaries. +- For full build and deployment instructions across all platforms, see [DEPLOY.md](DEPLOY.md). ## Credits diff --git a/tests/test_tui_cli.py b/tests/test_tui_cli.py index f77a124..bcb598e 100644 --- a/tests/test_tui_cli.py +++ b/tests/test_tui_cli.py @@ -201,9 +201,9 @@ def test_channels_view_removes_columns_in_narrow_terminals(self): text = "\n".join(manager._channels_lines(56)) self.assertIn("channel-1", text) - self.assertIn("drop", text) - self.assertNotIn("viewers", text) - self.assertNotIn("acl", text) + self.assertIn("Drop", text) + self.assertNotIn("Viewers", text) + self.assertNotIn("ACL", text) def test_drops_view_is_capped_scrollable_and_filterable(self): manager = self.make_manager() @@ -283,7 +283,8 @@ def test_drops_view_shows_allowed_offline_channels(self): narrow = "\n".join(manager._drops_lines(56)) self.assertIn("OfflineStreamer", wide) - self.assertIn("OfflineStreamer", narrow) + # Allowed column is hidden on narrow terminals (< 92 cols) + self.assertNotIn("OfflineStreamer", narrow) def test_drops_view_removes_columns_in_narrow_terminals(self): manager = self.make_manager() @@ -309,7 +310,7 @@ def test_drops_view_removes_columns_in_narrow_terminals(self): text = "\n".join(manager._drops_lines(56)) self.assertIn("Game", text) - self.assertIn("progress", text) + self.assertIn("Progress", text) self.assertNotIn(" yes ", text) self.assertNotIn("Long Campaign", text) diff --git a/tui/cli.py b/tui/cli.py index 1bc53e3..53d81b0 100644 --- a/tui/cli.py +++ b/tui/cli.py @@ -1,22 +1,31 @@ from __future__ import annotations +import os import asyncio import shutil +import time import webbrowser from datetime import datetime +from io import StringIO from typing import TYPE_CHECKING, Any, Callable from prompt_toolkit.application import Application from prompt_toolkit.completion import CompleteEvent, Completer, Completion from prompt_toolkit.document import Document from prompt_toolkit.filters import has_completions -from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.formatted_text import ANSI, AnyFormattedText from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.styles import Style from prompt_toolkit.widgets import Frame, TextArea +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.columns import Columns +from rich import box from core.constants import PriorityMode from tui.manager import TUIManager @@ -26,6 +35,48 @@ from core.utils import Game +# ── Splash screen logo ─────────────────────────────────────────────── +_LOGO = r""" +█▀█▄ █▀█▄ █▀██▀█▄ █ █▀█▄ █▀█▄ █▀█▄ + ▒█ ▒ ██ ▒ ██ ██ ▒ ▒ ██ ▒▄ ▒ + ▓█ ▓▄██ ▓ ▀ ██ ▓ ▓ ██ ▓▄██ ▓ +By HimanM +""" + +_SPLASH_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +_SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] + +# ── Color palette ──────────────────────────────────────────────────── +_C_CYAN = "#00d7ff" +_C_AMBER = "#ffaf00" +_C_GREEN = "#00d75f" +_C_RED = "#ff5f5f" +_C_YELLOW = "#ffd700" +_C_DIM = "#5f5f5f" +_C_TEXT = "#d7d7d7" +_C_BG = "#0a0a10" +_C_PANEL_BORDER = "#005f87" +_C_TAB_ACTIVE = "#00d7ff" +_C_TAB_INACTIVE = "#5f5f87" +_C_HEADER_BG = "#0f1520" + + +def _render_rich(rich_object: Any, width: int) -> str: + console = Console( + file=StringIO(), + width=width, + force_terminal=True, + no_color=False, + ) + console.print(rich_object) + return console.file.getvalue() # type: ignore[union-attr] + + +def _rich_to_pt(rich_object: Any, width: int) -> ANSI: + ansi_text = _render_rich(rich_object, width) + return ANSI(ansi_text) + + class CommandCompleter(Completer): """Slash-command completer that works from the whole input line.""" @@ -72,10 +123,12 @@ def _argument_completion(self, text: str) -> tuple[str, list[str], bool] | None: class PortableCLIManager(TUIManager): - """Full-screen prompt_toolkit frontend for portable terminal sessions.""" + """Full-screen prompt_toolkit frontend with Rich styling for portable terminal sessions.""" CHANNEL_PAGE_SIZE = 10 CAMPAIGN_PAGE_SIZE = 8 + SPLASH_DURATION = 2.0 + ANIMATION_FPS = 12 COMMANDS = ( "/dashboard", "/channels", @@ -118,7 +171,11 @@ class PortableCLIManager(TUIManager): "/farm-unlinked", "/farm-unlinked on", "/farm-unlinked off", + "/detach", "/help", + "/help navigation", + "/help control", + "/help system", "/quit", ) @@ -132,14 +189,24 @@ def __init__(self, twitch: Any) -> None: self._input: TextArea | None = None self._pt_app_task: asyncio.Task[None] | None = None self._clock_task: asyncio.Task[None] | None = None + self._animation_task: asyncio.Task[None] | None = None + self._spinner_tick = 0 + self._splash_start: float = 0.0 + self._splash_done = False + self._transition_alpha = 0 + self._transition_target: str | None = None + self._loading = False + self._help_topic: str | None = None def start(self) -> None: if self._pt_app_task is not None and not self._pt_app_task.done(): return self._app_ready.clear() self._pt_app = self._make_app() + self._splash_start = time.monotonic() self._pt_app_task = asyncio.create_task(self._pt_app.run_async()) self._clock_task = asyncio.create_task(self._clock_loop()) + self._animation_task = asyncio.create_task(self._animation_loop()) self._app_ready.set() async def wait_until_ready(self) -> None: @@ -149,6 +216,8 @@ def stop(self) -> None: super().stop() if self._clock_task is not None: self._clock_task.cancel() + if self._animation_task is not None: + self._animation_task.cancel() if self._pt_app is not None and self._pt_app.future is not None and not self._pt_app.future.done(): self._pt_app.exit() @@ -181,6 +250,8 @@ def refresh_campaigns(self) -> None: def refresh_settings(self) -> None: self._invalidate() + # ── App setup ──────────────────────────────────────────────────── + def _make_app(self) -> Application[None]: completer = CommandCompleter(self.COMMANDS, self._completion_candidates) self._input = TextArea( @@ -198,7 +269,7 @@ def _make_app(self) -> Application[None]: wrap_lines=False, always_hide_cursor=True, ), - Window(height=1, char="-", style="class:rule"), + Window(height=1, char="\u2500", style="class:rule"), Frame(self._input, title="command"), ConditionalContainer( CompletionsMenu(max_height=6, scroll_offset=1), @@ -221,28 +292,47 @@ def _quit(event) -> None: erase_when_done=False, style=Style.from_dict( { - "screen": "ansidefault bg:#101014", - "rule": "#5f5f5f", - "frame.border": "#00d7ff", - "frame.label": "bold #ffaf00", - "prompt": "bold #00d7ff", - "completion-menu.completion": "bg:#202028 #d7d7d7", - "completion-menu.completion.current": "bg:#005f87 #ffffff bold", + "screen": f"ansidefault bg:{_C_BG}", + "rule": _C_DIM, + "frame.border": _C_CYAN, + "frame.label": f"bold {_C_AMBER}", + "prompt": f"bold {_C_CYAN}", + "completion-menu.completion": f"bg:#202028 {_C_TEXT}", + "completion-menu.completion.current": f"bg:#005f87 #ffffff bold", "completion-menu.meta.completion": "bg:#202028 #878787", "completion-menu.meta.completion.current": "bg:#005f87 #ffffff", } ), ) + # ── Animation loops ────────────────────────────────────────────── + async def _clock_loop(self) -> None: while not self.close_requested: await asyncio.sleep(1) self._invalidate() + async def _animation_loop(self) -> None: + interval = 1.0 / self.ANIMATION_FPS + while not self.close_requested: + await asyncio.sleep(interval) + self._spinner_tick += 1 + if self._transition_target is not None: + self._transition_alpha = min(255, self._transition_alpha + 30) + self._invalidate() + def _invalidate(self) -> None: if self._pt_app is not None: self._pt_app.invalidate() + def _spinner_char(self) -> str: + return _SPINNER_FRAMES[self._spinner_tick % len(_SPINNER_FRAMES)] + + def _splash_spinner(self) -> str: + return _SPLASH_FRAMES[self._spinner_tick % len(_SPLASH_FRAMES)] + + # ── Command handling ───────────────────────────────────────────── + def _accept_command(self, buffer) -> bool: raw = buffer.text.strip() buffer.text = "" @@ -271,6 +361,7 @@ def _handle_command(self, raw: str) -> None: elif command == "logs": self._view = "logs" elif command == "reload": + self._loading = True self._reload() elif command == "open": self._open_login_url() @@ -289,14 +380,10 @@ def _handle_command(self, raw: str) -> None: self._handle_filter(rest) elif command == "farm-unlinked": self._set_farm_unlinked(rest.lower() in {"1", "on", "true", "yes"}) + elif command == "detach": + self._detach_tmux() elif command == "help": - self.print( - "Commands: /dashboard /channels [next|prev] /drops [next|prev] " - "/settings /logs /reload /switch /priority add " - "/priority remove /priority bump /priority demote " - "/exclude add /mode " - "/filter /quit" - ) + self._show_help(rest) else: self.print(f"Unknown command: {raw}") @@ -317,6 +404,8 @@ def _completion_candidates(self, command: str) -> list[str]: return ["on", "off"] if command == "/mode": return ["priority-only", "ending-soonest", "low-availability"] + if command == "/help": + return ["navigation", "control", "system"] return [] def _resolve_channel_id(self, value: str) -> str | None: @@ -415,6 +504,97 @@ def _handle_filter(self, rest: str) -> None: self._campaign_offset = 0 self.print(f"Campaign filter {name} set to {'on' if enabled else 'off'}.") + HELP_PAGES: dict[str, list[tuple[str, str]]] = { + "navigation": [ + ("/dashboard", "Switch to the dashboard view (default)"), + ("/channels", "Switch to the channels list view"), + ("/channels next", "Page forward in the channels list"), + ("/channels prev", "Page backward in the channels list"), + ("/drops", "Switch to the drops/campaigns view"), + ("/drops next", "Page forward in the drops list"), + ("/drops prev", "Page backward in the drops list"), + ("/settings", "Switch to the settings view"), + ("/logs", "Switch to the logs view"), + ], + "control": [ + ("/reload", "Reload inventory and campaign data from Twitch"), + ("/switch ", "Switch to a specific channel by name or ID"), + ("/priority add ", "Add a game to the priority list"), + ("/priority remove ", "Remove a game from the priority list"), + ("/priority bump ", "Move a game up in the priority list"), + ("/priority demote ", "Move a game down in the priority list"), + ("/exclude add ", "Add a game to the exclude list"), + ("/exclude remove ", "Remove a game from the exclude list"), + ("/mode ", "Set priority mode: priority-only, ending-soonest, low-availability"), + ("/filter ", "Toggle filters: not-linked, upcoming, expired, excluded, finished"), + ("/farm-unlinked on|off", "Enable/disable farming unlinked drops (priority-only mode)"), + ], + "system": [ + ("/open", "Open the Twitch login URL in a browser (when login is pending)"), + ("/copy", "Show the Twitch login URL (when login is pending)"), + ("/detach", "Detach from current tmux session (keeps miner running)"), + ("/quit", "Exit the application"), + ], + } + + def _show_help(self, topic: str) -> None: + topic = topic.strip().lower() + if topic and topic in self.HELP_PAGES: + self._help_topic = topic + self._view = "help" + return + if topic: + self.print(f"Unknown help topic: {topic}") + return + self._help_topic = None + self._view = "help" + + def _rich_help(self, width: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) + topic = getattr(self, "_help_topic", None) + if topic and topic in self.HELP_PAGES: + self._print_help_table(console, topic, self.HELP_PAGES[topic]) + else: + console.print(f"[bold {_C_CYAN}]Help[/] — type /help for details\n") + for name in self.HELP_PAGES: + console.print(f" [bold {_C_AMBER}]{name}[/] ({len(self.HELP_PAGES[name])} commands)") + console.print() + console.print(f"[dim {_C_DIM}] Examples: /help navigation, /help control, /help system[/]") + return console.file.getvalue() # type: ignore[union-attr] + + def _print_help_table(self, console: Console, title: str, commands: list[tuple[str, str]]) -> None: + from rich.table import Table as RichTable + from rich import box + table = RichTable( + title=f"[bold {_C_CYAN}]Help: {title.title()}[/]", + box=box.SIMPLE_HEAVY, + border_style=_C_PANEL_BORDER, + show_header=True, + padding=(0, 1), + expand=True, + ) + table.add_column("Command", style=f"bold {_C_AMBER}", min_width=24) + table.add_column("Description", style=_C_TEXT) + for cmd, desc in commands: + table.add_row(cmd, desc) + console.print(table) + + def _detach_tmux(self) -> None: + import subprocess + import shutil as _shutil + if not _shutil.which("tmux"): + self.print("tmux is not installed.") + return + env = os.environ.get("TMUX") + if not env: + self.print("Not running inside a tmux session.") + return + try: + subprocess.run(["tmux", "detach-client"], check=False) + self.print("Detached from tmux session.") + except Exception as e: + self.print(f"Failed to detach: {e}") + def _open_login_url(self) -> None: url = self.state.login.activation_url if url: @@ -433,172 +613,474 @@ def set_games(self, games: set[Game]) -> None: self._channel_offset = 0 self._campaign_offset = 0 - def _screen_fragments(self) -> AnyFormattedText: - return [("class:screen", self._screen_text())] + # ── Rich rendering ─────────────────────────────────────────────── - def _screen_text(self) -> str: + def _screen_fragments(self) -> AnyFormattedText: width, height = self._terminal_size() + ansi_text = self._render_screen(width, height) + return ANSI(ansi_text) + + def _render_screen(self, width: int, height: int) -> str: + now = time.monotonic() + elapsed = now - self._splash_start + if not self._splash_done and elapsed < self.SPLASH_DURATION: + return self._render_splash(width, height, elapsed) + self._splash_done = True + if self.state.login.activation_url: + return self._render_login(width, height) lines = [ - self._title(width), - self._tabs(width), - "", + self._rich_title(width), + self._rich_tabs(width), ] - if self.state.login.activation_url: - lines.extend(self._login_lines(width)) - elif self._view == "channels": - lines.extend(self._channels_lines(width)) + view_text = "" + if self._view == "channels": + view_text = self._rich_channels(width) elif self._view == "drops": - lines.extend(self._drops_lines(width)) + view_text = self._rich_drops(width) elif self._view == "settings": - lines.extend(self._settings_lines(width)) + view_text = self._rich_settings(width) elif self._view == "logs": - lines.extend(self._log_lines(width, height - len(lines) - 2)) + view_text = self._rich_logs(width, height - 4) + elif self._view == "help": + view_text = self._rich_help(width) else: - lines.extend(self._dashboard_lines(width, height - len(lines) - 2)) - return "\n".join(lines[: max(1, height - 2)]) + view_text = self._rich_dashboard(width, height - 4) + lines.append(view_text) + joined = "\n".join(lines) + return joined[: height * width] + + # ── Test compatibility wrappers ────────────────────────────────── + + @staticmethod + def _strip_ansi(text: str) -> str: + import re + return re.sub(r"\x1b\[[0-9;]*m", "", text) - def _title(self, width: int) -> str: - text = "TDMinER by HimanM" + def _screen_text(self) -> str: + width, height = self._terminal_size() + return self._strip_ansi(self._render_screen(width, height)) + + def _channels_lines(self, width: int) -> list[str]: + return self._strip_ansi(self._rich_channels(width)).splitlines() + + def _drops_lines(self, width: int) -> list[str]: + return self._strip_ansi(self._rich_drops(width)).splitlines() + + # ── Splash screen ──────────────────────────────────────────────── + + def _render_splash(self, width: int, height: int, elapsed: float) -> str: + spinner = self._splash_spinner() + progress = min(1.0, elapsed / self.SPLASH_DURATION) + bar_width = max(10, min(40, width - 10)) + filled = round(bar_width * progress) + bar = f"[{'#' * filled}{'-' * (bar_width - filled)}]" + console = Console(file=StringIO(), width=width, force_terminal=True) + console.print() + if width >= 50: + logo_text = _LOGO.strip("\n") + console.print( + Text(logo_text, style=f"bold {_C_CYAN}"), + justify="center", + ) + console.print() + console.print( + Text("Twitch Drops Miner", style=f"bold {_C_AMBER}"), + justify="center", + ) + else: + console.print( + Text("TDMinER", style=f"bold {_C_CYAN}"), + justify="center", + ) + console.print( + Text("by HimanM", style=_C_DIM), + justify="center", + ) + console.print() + status = f" {spinner} Loading{'.' * ((self._spinner_tick // 4) % 4)}" + console.print(Text(status, style=_C_TEXT)) + console.print() + console.print( + Text(f" {bar} {progress:.0%}", style=_C_GREEN), + ) + console.print() + console.print( + Text(" Press any key to skip...", style=_C_DIM), + justify="center", + ) + result = console.file.getvalue() # type: ignore[union-attr] + return result + + # ── Title & tabs ───────────────────────────────────────────────── + + def _rich_title(self, width: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) right = datetime.now().strftime("%H:%M:%S") - if width < 48: - return text[:width] - gap = max(1, width - len(text) - len(right)) - return f"{text}{' ' * gap}{right}"[:width] - - def _tabs(self, width: int) -> str: - tabs = ["dashboard", "drops", "channels", "settings", "logs"] - parts = [] - for tab in tabs: - parts.append(f"[{tab}]" if self._view == tab or tab == "dashboard" and self._view == "dashboard" else f" {tab} ") - return " ".join(parts)[:width] - - def _dashboard_lines(self, width: int, available_height: int) -> list[str]: + title = Text() + title.append("TDMinER ", style=f"bold {_C_CYAN}") + title.append("by HimanM", style=_C_DIM) + padding = max(1, width - len("TDMinER by HimanM") - len(right) - 2) + title.append(" " * padding) + title.append(right, style=_C_DIM) + console.print(title) + return console.file.getvalue().rstrip("\n") # type: ignore[union-attr] + + def _rich_tabs(self, width: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) + tabs = ["dashboard", "drops", "channels", "settings", "logs", "help"] + t = Text() + for i, tab in enumerate(tabs): + if i > 0: + t.append(" ") + if tab == self._view: + t.append(f"[{tab}]", style=f"bold {_C_TAB_ACTIVE}") + else: + t.append(f" {tab} ", style=_C_TAB_INACTIVE) + console.print(t) + return console.file.getvalue().rstrip("\n") # type: ignore[union-attr] + + # ── Dashboard view ─────────────────────────────────────────────── + + def _rich_dashboard(self, width: int, available_height: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) drop = self.state.current_drop watching = next((ch.name for ch in self.state.channels.values() if ch.watching), "-") websockets = sum(1 for ws in self.state.websockets.values() if "connected" in ws.status.lower()) - lines = [ - self._section("status", width), - f"status {self.state.status}", - f"watching {watching}", - f"websockets {websockets} connected", - f"mode {self.state.priority_mode}", - f"farm unlinked {'on' if self.state.farm_unlinked else 'off'}", - "", - self._section("current drop", width), - drop.game, - drop.rewards, - f"remaining {drop.remaining}", - self._bar("drop", drop.drop_progress, width), - self._bar("campaign", drop.campaign_progress, width), - "", - self._section("logs", width), - ] - lines.extend(self.state.logs[-max(1, available_height - len(lines)) :] or ["No recent activity"]) - return [line[:width] for line in lines] + spinner = self._spinner_char() + narrow = width < 60 - def _channels_lines(self, width: int) -> list[str]: + # Status panel + status_table = Table(show_header=False, box=None, padding=(0, 1)) + status_table.add_column(style=_C_DIM, width=14) + status_table.add_column() + status_icon = _status_icon(self.state.status) + status_table.add_row("Status", Text(f"{status_icon} {self.state.status}", style=_C_TEXT)) + if not narrow: + status_table.add_row("Watching", Text(watching, style=_C_CYAN if watching != "-" else _C_DIM)) + status_table.add_row("Websockets", Text(f"{spinner} {websockets} connected", style=_C_GREEN if websockets > 0 else _C_YELLOW)) + status_table.add_row("Mode", Text(self.state.priority_mode, style=_C_AMBER)) + status_table.add_row("Farm unlinked", Text("on" if self.state.farm_unlinked else "off", style=_C_GREEN if self.state.farm_unlinked else _C_DIM)) + + status_panel = Panel(status_table, title=f"[bold {_C_CYAN}]Status[/]", border_style=_C_PANEL_BORDER) + + # Drop progress panel + drop_table = Table(show_header=False, box=None, padding=(0, 1)) + drop_table.add_column(style=_C_DIM, width=14) + drop_table.add_column() + drop_table.add_row("Game", Text(drop.game, style="bold")) + if not narrow: + drop_table.add_row("Rewards", Text(drop.rewards, style=_C_AMBER)) + drop_table.add_row("Remaining", Text(drop.remaining, style=_C_YELLOW)) + + bar_width = max(8, min(30, width - 20)) + drop_bar = self._rich_progress_bar(drop.drop_progress, width=bar_width, label="Drop") + campaign_bar = self._rich_progress_bar(drop.campaign_progress, width=bar_width, label="Campaign") + + progress_content = Table(show_header=False, box=None) + progress_content.add_column() + progress_content.add_row(drop_table) + progress_content.add_row(Text()) + progress_content.add_row(drop_bar) + progress_content.add_row(Text()) + progress_content.add_row(campaign_bar) + + progress_panel = Panel(progress_content, title=f"[bold {_C_AMBER}]Current Drop[/]", border_style=_C_PANEL_BORDER) + + if narrow: + console.print(status_panel) + console.print(progress_panel) + else: + from rich.columns import Columns as RichColumns + panels = RichColumns([status_panel, progress_panel], padding=1, expand=False) + console.print(panels) + + # Logs section + console.print(f"[bold {_C_DIM}]{'─' * width}[/]") + console.print(f"[bold {_C_AMBER}]Logs[/]") + logs = self.state.logs[-max(1, available_height - 12):] or ["No recent activity"] + for line in logs: + console.print(f" {_dim_log(line)}") + + return console.file.getvalue() # type: ignore[union-attr] + + # ── Channels view ──────────────────────────────────────────────── + + def _rich_channels(self, width: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) channels = list(self.state.channels.values()) page = channels[self._channel_offset : self._channel_offset + self.CHANNEL_PAGE_SIZE] - lines = [self._section(f"channels {self._page_label(self._channel_offset, self.CHANNEL_PAGE_SIZE, len(channels))}", width)] - if width < 64: - lines.append(f"{'':1} {'channel':24} {'status':10} {'drop':4}") - for channel in page: - marker = ">" if channel.watching else " " - lines.append(f"{marker:1} {channel.name[:24]:24} {channel.status[:10]:10} {'yes' if channel.drops else 'no':4}") - elif width < 92: - lines.append(f"{'':1} {'channel':24} {'status':10} {'game':20} {'drop':4}") - for channel in page: - marker = ">" if channel.watching else " " - lines.append( - f"{marker:1} {channel.name[:24]:24} {channel.status[:10]:10} {channel.game[:20]:20} {'yes' if channel.drops else 'no':4}" - ) - else: - lines.append(f"{'':1} {'channel':24} {'status':10} {'game':20} {'drop':4} {'viewers':8} {'acl':3}") - for channel in page: - marker = ">" if channel.watching else " " - lines.append( - f"{marker:1} {channel.name[:24]:24} {channel.status[:10]:10} {channel.game[:20]:20} " - f"{'yes' if channel.drops else 'no':4} {channel.viewers[:8]:8} {'yes' if channel.acl_based else 'no':3}" - ) - lines.append("Use /channels next or /channels prev.") - return [line[:width] for line in lines] + total = len(channels) + page_label = self._page_label(self._channel_offset, self.CHANNEL_PAGE_SIZE, total) - def _drops_lines(self, width: int) -> list[str]: + table = Table( + title=f"[bold {_C_CYAN}]Channels[/] {page_label}", + box=box.ROUNDED, + border_style=_C_PANEL_BORDER, + title_style=_C_CYAN, + show_lines=False, + expand=True, + ) + table.add_column("", width=2, justify="center") + table.add_column("Channel", style="bold", max_width=24) + table.add_column("Status", width=10) + table.add_column("Game", max_width=20) + table.add_column("Drop", width=5, justify="center") + if width >= 92: + table.add_column("Viewers", width=8, justify="right") + table.add_column("ACL", width=4, justify="center") + + for channel in page: + status_style = _channel_status_style(channel.status) + if channel.watching: + marker = Text() + marker.append("> ", style=f"bold {_C_GREEN}") + else: + marker = Text(" ") + row = [ + marker, + Text(channel.name, style="bold" if channel.watching else ""), + Text(channel.status, style=status_style), + Text(channel.game[:20], style=_C_TEXT), + Text("yes" if channel.drops else "no", style=_C_GREEN if channel.drops else _C_RED), + ] + if width >= 92: + row.append(Text(channel.viewers[:8], style=_C_TEXT)) + row.append(Text("yes" if channel.acl_based else "no", style=_C_GREEN if channel.acl_based else _C_DIM)) + table.add_row(*row) + + console.print(table) + console.print(f"[dim {_C_DIM}] Use /channels next or /channels prev to navigate.[/]") + return console.file.getvalue() # type: ignore[union-attr] + + # ── Drops/Campaigns view ───────────────────────────────────────── + + def _rich_drops(self, width: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) campaigns = self._visible_campaigns() self._campaign_offset = min(self._campaign_offset, max(0, len(campaigns) - self.CAMPAIGN_PAGE_SIZE)) page = campaigns[self._campaign_offset : self._campaign_offset + self.CAMPAIGN_PAGE_SIZE] - lines = [self._section(f"drops {self._page_label(self._campaign_offset, self.CAMPAIGN_PAGE_SIZE, len(campaigns))}", width)] - if width < 64: - lines.append(f"{'game':22} {'status':10} {'progress':8}") - for campaign in page: - lines.append(f"{campaign.game[:22]:22} {campaign.status[:10]:10} {campaign.percent:8}") - if campaign.allowed_channels: - lines.append(f" allowed {campaign.allowed_channels}"[:width]) - elif width < 92: - lines.append(f"{'game':18} {'campaign':26} {'progress':8} {'allowed':20}") - for campaign in page: - lines.append( - f"{campaign.game[:18]:18} {campaign.name[:26]:26} " - f"{campaign.percent:8} {campaign.allowed_channels[:20]:20}" - ) - else: - lines.append(f"{'game':16} {'campaign':28} {'status':10} {'linked':6} {'progress':8} {'allowed':24}") - for campaign in page: - lines.append( - f"{campaign.game[:16]:16} {campaign.name[:28]:28} {campaign.status[:10]:10} " - f"{'yes' if campaign.linked else 'no':6} {campaign.percent:8} {campaign.allowed_channels[:24]:24}" - ) + total = len(campaigns) + page_label = self._page_label(self._campaign_offset, self.CAMPAIGN_PAGE_SIZE, total) + + narrow = width < 60 + medium = width < 72 + + table = Table( + title=f"[bold {_C_AMBER}]Drops[/] {page_label}", + box=box.ROUNDED, + border_style=_C_PANEL_BORDER, + title_style=_C_AMBER, + show_lines=False, + expand=True, + ) + table.add_column("Game", style="bold", max_width=18 if not narrow else 14) + if not narrow: + table.add_column("Campaign", max_width=26 if not medium else 18) + table.add_column("Status", width=10 if not narrow else 8) + if not narrow: + table.add_column("Linked", width=6, justify="center") + table.add_column("Progress", width=12) + if width >= 92: + table.add_column("Allowed", max_width=24) + + for campaign in page: + status_style = _campaign_status_style(campaign) + linked_style = _C_GREEN if campaign.linked else _C_RED + progress_bar = self._rich_mini_bar(campaign.progress, width=6 if narrow else 8) + row = [ + Text(campaign.game[:18 if not narrow else 14], style="bold"), + ] + if not narrow: + row.append(Text(campaign.name[:26 if not medium else 18])) + row.append(Text(campaign.status, style=status_style)) + if not narrow: + row.append(Text("yes" if campaign.linked else "no", style=linked_style)) + row.append(Text(f"{progress_bar} {campaign.percent}", style=_C_TEXT)) + if width >= 92: + row.append(Text(campaign.allowed_channels[:24], style=_C_DIM)) + table.add_row(*row) + + console.print(table) filters = self.state.campaign_filters - lines.append( - f"filters: not-linked={'on' if filters.show_not_linked else 'off'} " - f"upcoming={'on' if filters.show_upcoming else 'off'} expired={'on' if filters.show_expired else 'off'} " - f"excluded={'on' if filters.show_excluded else 'off'} finished={'on' if filters.show_finished else 'off'}" + filter_text = Text() + filter_text.append("filters: ", style=_C_DIM) + for name, val in [ + ("not-linked", filters.show_not_linked), + ("upcoming", filters.show_upcoming), + ("expired", filters.show_expired), + ("excluded", filters.show_excluded), + ("finished", filters.show_finished), + ]: + state = "on" if val else "off" + style = _C_GREEN if val else _C_DIM + filter_text.append(f"{name}={state} ", style=style) + console.print(filter_text) + return console.file.getvalue() # type: ignore[union-attr] + + # ── Settings view ──────────────────────────────────────────────── + + def _rich_settings(self, width: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) + + table = Table( + title=f"[bold {_C_GREEN}]Settings[/]", + box=box.ROUNDED, + border_style=_C_PANEL_BORDER, + title_style=_C_GREEN, + show_header=False, + padding=(0, 2), ) - return [line[:width] for line in lines] + table.add_column(style=_C_DIM, width=16) + table.add_column() + table.add_row("Mode", Text(self.state.priority_mode, style=_C_AMBER)) + table.add_row("Farm unlinked", Text("on" if self.state.farm_unlinked else "off", style=_C_GREEN if self.state.farm_unlinked else _C_DIM)) + table.add_row("Available", Text(f"{len(self.state.available_games)} games", style=_C_TEXT)) - def _settings_lines(self, width: int) -> list[str]: - lines = [ - self._section("settings", width), - f"priority mode {self.state.priority_mode}", - f"farm unlinked {'on' if self.state.farm_unlinked else 'off'}", - f"available {len(self.state.available_games)} games", - f"priority {', '.join(self.state.priority) or '-'}", - f"exclude {', '.join(self.state.exclude) or '-'}", - "", - "/priority add /priority remove ", - "/priority bump /priority demote ", - "/exclude add /mode ", - "/farm-unlinked on|off (only works in priority-only mode)", - ] - return [line[:width] for line in lines] + console.print(table) + + has_priority = bool(self.state.priority) + has_exclude = bool(self.state.exclude) + side_by_side = width >= 64 and has_priority and has_exclude - def _log_lines(self, width: int, available_height: int) -> list[str]: - lines = [self._section("logs", width)] - lines.extend(self.state.logs[-max(1, available_height - 1) :] or ["No recent activity"]) - return [line[:width] for line in lines] + if side_by_side: + from rich.columns import Columns as RichColumns + panels = [] + ptable = Table( + title=f"[bold {_C_CYAN}]Priority List[/]", + box=box.SIMPLE, + border_style=_C_PANEL_BORDER, + show_header=True, + ) + ptable.add_column("#", width=3, style=_C_DIM) + ptable.add_column("Game", style="bold") + for idx, game in enumerate(self.state.priority, start=1): + ptable.add_row(str(idx), game) + panels.append(ptable) - def _login_lines(self, width: int) -> list[str]: + etable = Table( + title=f"[bold {_C_RED}]Exclude List[/]", + box=box.SIMPLE, + border_style=_C_PANEL_BORDER, + show_header=True, + ) + etable.add_column("Game", style="bold") + for game in self.state.exclude: + etable.add_row(game) + panels.append(etable) + + console.print() + console.print(RichColumns(panels, padding=2, expand=False)) + else: + if has_priority: + console.print() + ptable = Table( + title=f"[bold {_C_CYAN}]Priority List[/]", + box=box.SIMPLE, + border_style=_C_PANEL_BORDER, + show_header=True, + ) + ptable.add_column("#", width=3, style=_C_DIM) + ptable.add_column("Game", style="bold") + for idx, game in enumerate(self.state.priority, start=1): + ptable.add_row(str(idx), game) + console.print(ptable) + + if has_exclude: + console.print() + etable = Table( + title=f"[bold {_C_RED}]Exclude List[/]", + box=box.SIMPLE, + border_style=_C_PANEL_BORDER, + show_header=True, + ) + etable.add_column("Game", style="bold") + for game in self.state.exclude: + etable.add_row(game) + console.print(etable) + + console.print() + console.print(Text(" /priority add /priority remove ", style=_C_DIM)) + console.print(Text(" /priority bump /priority demote ", style=_C_DIM)) + console.print(Text(" /exclude add /mode ", style=_C_DIM)) + console.print(Text(" /farm-unlinked on|off (only works in priority-only mode)", style=_C_DIM)) + + return console.file.getvalue() # type: ignore[union-attr] + + # ── Logs view ──────────────────────────────────────────────────── + + def _rich_logs(self, width: int, available_height: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) + console.print(f"[bold {_C_AMBER}]Logs[/]") + logs = self.state.logs[-max(1, available_height):] or ["No recent activity"] + for line in logs: + console.print(f" {_dim_log(line)}") + return console.file.getvalue() # type: ignore[union-attr] + + # ── Login view ─────────────────────────────────────────────────── + + def _render_login(self, width: int, height: int) -> str: + console = Console(file=StringIO(), width=width, force_terminal=True) login = self.state.login - lines = [ - self._section("login", width), - "Twitch device login required", - f"URL: {login.activation_url}", - f"Code: {login.user_code}", - "Commands: /open /copy /quit", - ] - return [line[:width] for line in lines] + spinner = self._spinner_char() - def _bar(self, label: str, value: float, width: int) -> str: + table = Table(show_header=False, box=None, padding=(0, 1)) + table.add_column(style=_C_DIM, width=8) + table.add_column() + table.add_row("Status", Text(login.status, style=_C_YELLOW)) + table.add_row("User", Text(login.user_id, style=_C_TEXT)) + table.add_row("Code", Text(login.user_code, style=f"bold {_C_YELLOW}")) + + url_text = Text() + url_text.append("URL: ", style=_C_DIM) + url_text.append(login.activation_url, style=f"bold {_C_CYAN}") + + content = Table(show_header=False, box=None) + content.add_column() + content.add_row(table) + content.add_row(Text()) + content.add_row(url_text) + content.add_row(Text()) + content.add_row(Text(f" {spinner} Waiting for activation...", style=_C_AMBER)) + content.add_row(Text()) + content.add_row(Text(" Commands: /open /copy /quit", style=_C_DIM)) + + panel = Panel( + content, + title=f"[bold {_C_YELLOW}]Twitch Login Required[/]", + border_style=_C_YELLOW, + padding=(1, 2), + ) + console.print(panel) + return console.file.getvalue() # type: ignore[union-attr] + + # ── Rich progress bar helpers ──────────────────────────────────── + + def _rich_progress_bar(self, value: float, width: int = 30, label: str = "") -> Text: percent = max(0.0, min(1.0, value)) - bar_width = max(8, min(40, width - len(label) - 12)) - done = round(bar_width * percent) - return f"{label:<9} [{'#' * done}{'-' * (bar_width - done)}] {percent:>5.1%}" + filled = round(width * percent) + empty = width - filled + bar = Text() + if label: + bar.append(f"{label:<9} ", style=_C_DIM) + bar.append("[", style=_C_DIM) + bar.append("\u2588" * filled, style=_progress_color(percent)) + bar.append("\u2500" * empty, style="#2a2a2a") + bar.append("] ", style=_C_DIM) + bar.append(f"{percent:>6.1%}", style=_progress_color(percent)) + return bar - @staticmethod - def _section(label: str, width: int) -> str: - text = f" {label} " - side = max(0, (width - len(text)) // 2) - return f"{'-' * side}{text}{'-' * max(0, width - side - len(text))}" + def _rich_mini_bar(self, value: float, width: int = 8) -> Text: + percent = max(0.0, min(1.0, value)) + filled = round(width * percent) + empty = width - filled + bar = Text() + bar.append("[", style=_C_DIM) + bar.append("\u2588" * filled, style=_progress_color(percent)) + bar.append("\u2500" * empty, style="#2a2a2a") + bar.append("]", style=_C_DIM) + return bar + + # ── Misc helpers ───────────────────────────────────────────────── @staticmethod def _terminal_size() -> tuple[int, int]: @@ -631,3 +1113,47 @@ def _page_label(offset: int, page_size: int, total: int) -> str: start = offset + 1 end = min(total, offset + page_size) return f"({start}-{end}/{total})" + + +# ── Module-level helpers ────────────────────────────────────────────── + +def _status_icon(status: str) -> str: + s = status.lower() + if "running" in s or "watching" in s or "connected" in s: + return "\u25cf" # green circle (via style) + if "idle" in s or "waiting" in s or "pending" in s: + return "\u25cb" # yellow circle + if "error" in s or "failed" in s: + return "\u25cf" # red circle + return "\u25cb" # default + + +def _channel_status_style(status: str) -> str: + s = status.lower() + if "online" in s: + return _C_GREEN + if "pending" in s: + return _C_YELLOW + return _C_RED + + +def _campaign_status_style(campaign: CampaignSnapshot) -> str: + if campaign.active: + return _C_GREEN + if campaign.upcoming: + return _C_CYAN + if campaign.expired: + return _C_RED + return _C_DIM + + +def _progress_color(percent: float) -> str: + if percent >= 0.75: + return _C_GREEN + if percent >= 0.40: + return _C_YELLOW + return _C_RED + + +def _dim_log(line: str) -> str: + return f"[{_C_DIM}]{line}[/]" diff --git a/tui/state.py b/tui/state.py index 64dfe76..910efbe 100644 --- a/tui/state.py +++ b/tui/state.py @@ -121,6 +121,8 @@ class TUIState: campaigns: dict[str, CampaignSnapshot] = field(default_factory=dict) websockets: dict[int, WebsocketSnapshot] = field(default_factory=dict) logs: list[str] = field(default_factory=list) + splash_shown: bool = False + spinner_tick: int = 0 def add_log(self, message: str) -> None: self.logs.append(message) diff --git a/version.py b/version.py index 812a259..9cbc234 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -__version__ = "19.4.Opinionated_By_HimanM" +__version__ = "19.5.Opinionated_By_HimanM"