From db9ec3e334fac81338a65c59011b417f79dd673a Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 13 Aug 2026 16:25:14 -0600 Subject: [PATCH] feat(cli): add named color themes and rich-formatted help Render all click help through rich-click, themed by a new `theme` config key (9 palettes + aliases, previewable via `unshackle env theme`), and show service docstrings in local and remote `dl -h`. --- docs/guide/cli-reference.md | 9 + docs/reference/configuration/index.md | 1 + docs/reference/configuration/misc.md | 8 + docs/reference/configuration/services.md | 4 +- pyproject.toml | 1 + unshackle/commands/dl.py | 12 +- unshackle/commands/env.py | 105 +++++++++++ unshackle/core/__init__.py | 4 + unshackle/core/commands.py | 11 +- unshackle/core/config.py | 1 + unshackle/core/console.py | 30 ++- unshackle/core/music/display.py | 3 +- unshackle/core/music/renderer.py | 5 +- unshackle/core/services.py | 38 +++- unshackle/core/themes.py | 231 +++++++++++++++++++++++ uv.lock | 16 ++ 16 files changed, 439 insertions(+), 40 deletions(-) create mode 100644 unshackle/core/themes.py diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index a2b56c35..8a3134db 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -322,6 +322,14 @@ unshackle env info Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more). +### `env theme` + +``` +unshackle env theme +``` + +Prints a sample of every available CLI theme: colour swatches, help text with option rows, a track listing with video/audio/subtitle entries, log lines, a progress bar, and the gradient pulse bar. The active theme is marked and each theme's aliases are listed. Set your choice with the [`theme`](../reference/configuration/misc.md#appearance) config key. + ### `env clear` Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported. @@ -335,6 +343,7 @@ Clear an environment directory. The directory is emptied and recreated, and the ```shell unshackle env check unshackle env info + unshackle env theme unshackle env clear cache unshackle env clear cache EXAMPLE # one service only unshackle env clear temp diff --git a/docs/reference/configuration/index.md b/docs/reference/configuration/index.md index 2c389669..eff698ef 100644 --- a/docs/reference/configuration/index.md +++ b/docs/reference/configuration/index.md @@ -38,5 +38,6 @@ Where the file lives, how unshackle discovers it, and how to edit it: | [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` | | [External API keys](misc.md#external-api-keys) | `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `anilist_title_language`, `disable_metadata`, `decrypt_labs_api_key`, `ipinfo_api_key` | | [Caching & updates](misc.md#caching-updates) | `title_cache_enabled`, `title_cache_time`, `title_cache_max_retention`, `update_checks`, `update_check_interval` | +| [Appearance](misc.md#appearance) | `theme` | | [Logging, privacy & debug](misc.md#logging-privacy-debug) | `redact_paths`, `debug`, `debug_keys`, `debug_requests`, `set_terminal_bg` | | [Deprecated & removed](misc.md#deprecated-removed-keys) | `curl_impersonate`, `downloader`, `scene_naming` | diff --git a/docs/reference/configuration/misc.md b/docs/reference/configuration/misc.md index 33c82b49..5e989b67 100644 --- a/docs/reference/configuration/misc.md +++ b/docs/reference/configuration/misc.md @@ -141,6 +141,14 @@ calls. --- +## Appearance { #appearance } + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `theme` | str | `catppuccin-mocha` | Named colour theme for all CLI output: banner, logs, progress bars, and help screens. One of `catppuccin-mocha` (aliases `default`, `catppuccin`, `mocha`), `dracula`, `nord`, `gruvbox`, `one-dark` (alias `onedark`), `tokyo-night` (alias `tokyonight`), `monokai`, `flexoki`, `solarized-dark` (alias `solarized`). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all with `unshackle env theme`. | + +--- + ## Logging, privacy & debug { #logging-privacy-debug } | Key | Type | Default | Description | diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index 405b4236..caf3756b 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -95,7 +95,9 @@ not repeated in the fallbacks, so `auth_headers: ["Authorization", "x-secret-key In `--remote` mode unshackle turns the server's service list into synthetic CLI commands that run against it, falling back to the tags in that `services` sub-dict when the -list cannot be fetched. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the +list cannot be fetched. Each synthetic command carries the server-side service's options and +documentation, so `unshackle dl --remote -h` shows the same help text as it does on the +server. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the full setup. ## `serve` diff --git a/pyproject.toml b/pyproject.toml index 564beb96..8540266d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dependencies = [ "jsonpath-ng>=1.8.0", "mutagen>=1.47.0,<2", "defusedxml>=0.7.1", + "rich-click>=1.9.8", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 7ecc6f76..3dc1f851 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -30,6 +30,7 @@ import yaml from click.core import ParameterSource from langcodes import Language, tag_is_valid from pymediainfo import MediaInfo +from rich import box from rich.console import Group from rich.padding import Padding from rich.panel import Panel @@ -1766,7 +1767,7 @@ class dl: # Note: Headers are not mapped to actual title indices # Format display name - display_name = ((t.name[:30].rstrip() + "…") if len(t.name) > 30 else t.name) if t.name else None + display_name = ((t.name[:30].rstrip() + "...") if len(t.name) > 30 else t.name) if t.name else None # Apply indentation only for multiple seasons prefix = " " if multiple_seasons else "" @@ -2582,7 +2583,12 @@ class dl: if list_: available_tracks, _ = title.tracks.tree() - console.print(Padding(Panel(available_tracks, title="Available Tracks"), (0, 5))) + console.print( + Padding( + Panel(available_tracks, title="Available Tracks", box=box.SQUARE, border_style="bright_black"), + (0, 5), + ) + ) continue # Determine which tracks to keep @@ -4166,7 +4172,7 @@ class dl: Carries no session/cookies/dl-flags. Region (country code) is stored only when the export used ``--proxy``, as an import geofence. Each track records only the licensed DRM system; content keys live once under the track's ``keys``. ``drm`` may be None - (DRM-free track) or a DRM system without ``to_dict``/``content_keys`` (e.g. ClearKey) — + (DRM-free track) or a DRM system without ``to_dict``/``content_keys`` (e.g. ClearKey) - the track, manifest, chapter and attachment info is still exported. """ with self.EXPORT_LOCK: diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index 5af97864..f3181dd2 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -188,6 +188,111 @@ def check() -> None: console.print(Padding(" ".join(summary_parts), (1, 2))) +@env.command() +def theme() -> None: + """Preview the available CLI themes.""" + import math + + from rich import box + from rich.color import Color, blend_rgb + from rich.console import Group + from rich.panel import Panel + from rich.rule import Rule + from rich.style import Style + from rich.text import Text + + from unshackle.core.themes import ALIASES, DEFAULT_THEME, PALETTES, resolve_palette + + active = resolve_palette(config.theme) or PALETTES[DEFAULT_THEME] + for name, palette in PALETTES.items(): + text, text2, gray, guide = palette["text"], palette["text2"], palette["gray"], palette["bright_black"] + + header = Text(name, style=palette["pink"]) + if palette == active: + header.append(" (active)", style=palette["green"]) + aliases = sorted(alias for alias, target in ALIASES.items() if target == name) + if aliases: + header.append(f" aliases: {', '.join(aliases)}", style=f"dim {gray}") + + swatch = Text() + for role in ("red", "green", "yellow", "blue", "pink", "cyan", "text", "gray"): + swatch.append("██ ", style=palette[role]) + + docstring = Text() + docstring.append("Service docstrings render like this, ", style=text) + docstring.append("with secondary detail lines like this.", style=text2) + + separator = Text("─" * 60, style=palette["dark_gray"]) + options: list[Text] = [] + rows = ( + ("-q, ", "--quality ", "TEXT ", "Video quality to download", " [default: best]", f"dim {gray}"), + ( + "-r, ", + "--range ", + "[SDR|HDR10|DV] ", + "Video color range", + " [env: RANGE]", + f"dim {palette['yellow']}", + ), + ) + for short, long_opt, metavar, help_text, extra, extra_style in rows: + row = Text() + row.append(short, style=palette["green"]) + row.append(long_opt, style=text) + row.append(metavar, style=palette["yellow"]) + row.append(f" {help_text}", style=text) + row.append(extra, style=extra_style) + options.extend((row, separator)) + + listing = Tree(Text("1 season, S1(1)", style=text), guide_style=guide) + episode = listing.add(Text.assemble(("1. ", palette["blue"]), ("Pilot", text)), guide_style=guide) + track_rows = ( + ("VID", "H.264 | 1920x1080 @ 4523 kb/s"), + ("AUD", "AAC 2.0 | en @ 192 kb/s"), + ("SUB", "WebVTT | en (SDH)"), + ) + for kind, desc in track_rows: + episode.add(Text.assemble((kind, palette["pink"]), (" | ", guide), (desc, text)), guide_style=guide) + tracks = Panel(listing, title="Available Tracks", box=box.SQUARE, border_style=guide, expand=False) + + logs = [] + log_rows = ( + ("INFO", palette["green"], "Downloading 3 tracks"), + ("WARNING", palette["yellow"], "Subtitle cues overlapped, fixed 2"), + ("ERROR", palette["red"], "License request denied (403)"), + ) + for level, level_style, message in log_rows: + line = Text() + line.append("18:00:01 ", style=gray) + line.append(f"{level:<8} ", style=level_style) + line.append(message, style=text if level != "ERROR" else palette["red"]) + logs.append(line) + + progress = Text() + progress.append("Pilot ", style=text) + progress.append("━" * 24, style=palette["green"]) + progress.append("╸", style=palette["green"]) + progress.append("━" * 14, style=guide) + progress.append(" 62%", style=palette["yellow"]) + progress.append(" 12.4 MB/s", style=palette["cyan"]) + progress.append(" 0:00:42", style=text2) + + # one frozen frame of GradientPulseBarColumn's dark_gray-to-pink cosine sweep + lo = Color.parse(palette["dark_gray"]).triplet + hi = Color.parse(palette["pink"]).triplet + assert lo and hi + lut = [Style(color=Color.from_triplet(blend_rgb(lo, hi, i / 31))) for i in range(32)] + pulse = Text("Muxing ", style=text) + for i in range(39): + fade = (math.cos((i - 8) * math.tau / 40.0) + 1) / 2 + pulse.append("━", style=lut[int(fade * 31)]) + + block = Group(header, swatch, Text(), docstring, *options, tracks, *logs, Text(), progress, pulse) + console.print(Padding(block, (1, 2, 1, 2))) + console.print(Rule(style=guide, characters="─")) + console.print() + + @env.command() def info() -> None: """Displays information about the current environment.""" diff --git a/unshackle/core/__init__.py b/unshackle/core/__init__.py index 130bca20..1a9ef58f 100644 --- a/unshackle/core/__init__.py +++ b/unshackle/core/__init__.py @@ -3,6 +3,10 @@ import os from pathlib import Path from typing import Callable, Optional +from rich_click.patch import patch as _patch_click_help + +_patch_click_help() + __version__ = "5.3.0" _PKG = Path(__file__).parent.parent diff --git a/unshackle/core/commands.py b/unshackle/core/commands.py index 24bb7d2b..5d90c13a 100644 --- a/unshackle/core/commands.py +++ b/unshackle/core/commands.py @@ -23,12 +23,12 @@ def load_command(path: Path) -> object: try: module = import_module_by_path(path) except Exception as e: - raise RuntimeError(f"{path.stem}: failed to import — {type(e).__name__}: {e} ({path})") from e + raise RuntimeError(f"{path.stem}: failed to import - {type(e).__name__}: {e} ({path})") from e try: return getattr(module, path.stem) except AttributeError as e: raise RuntimeError( - f"{path.stem}: no object named '{path.stem}' found in {path} — it must match the filename" + f"{path.stem}: no object named '{path.stem}' found in {path} - it must match the filename" ) from e @@ -74,10 +74,9 @@ class Commands(click.Group): if not module: raise click.ClickException(f"Unable to find command by the name '{name}'") - if hasattr(module, "cli"): - return module.cli - - return module + cmd = module.cli if hasattr(module, "cli") else module + cmd.name = name + return cmd # Hide direct access to commands from quick import form, they shouldn't be accessed directly diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 265b129d..d1fcce5c 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -110,6 +110,7 @@ class Config: self.decryption_map = {} self.decryption = decryption_cfg or "shaka" + self.theme: str = kwargs.get("theme") or "default" self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False) self.tag: str = kwargs.get("tag") or "" self.tag_group_name: bool = kwargs.get("tag_group_name", True) diff --git a/unshackle/core/console.py b/unshackle/core/console.py index 52c0415f..bbf58ba4 100644 --- a/unshackle/core/console.py +++ b/unshackle/core/console.py @@ -21,6 +21,7 @@ from rich.text import Text, TextType from rich.theme import Theme from unshackle.core.config import config +from unshackle.core.themes import DEFAULT_THEME, PALETTES, apply_help_theme, resolve_palette class ComfyLogRenderer(LogRender): @@ -317,25 +318,16 @@ class ComfyConsole(Console): live.start() -catppuccin_mocha = { - # Colors based on "CatppuccinMocha" from Gogh themes - "bg": "rgb(30,30,46)", - "text": "rgb(205,214,244)", - "text2": "rgb(162,169,193)", # slightly darker - "black": "rgb(69,71,90)", - "bright_black": "rgb(88,91,112)", - "red": "rgb(243,139,168)", - "green": "rgb(166,227,161)", - "yellow": "rgb(249,226,175)", - "blue": "rgb(137,180,250)", - "pink": "rgb(245,194,231)", - "cyan": "rgb(148,226,213)", - "gray": "rgb(166,173,200)", - "bright_gray": "rgb(186,194,222)", - "dark_gray": "rgb(54,54,84)", -} - -primary_scheme = catppuccin_mocha +primary_scheme = resolve_palette(config.theme) +if primary_scheme is None: + logging.getLogger("console").warning( + "Unknown theme %r, falling back to %s (available: %s)", + config.theme, + DEFAULT_THEME, + ", ".join(PALETTES), + ) + primary_scheme = dict(PALETTES[DEFAULT_THEME]) +apply_help_theme(primary_scheme) primary_scheme["none"] = primary_scheme["text"] primary_scheme["grey23"] = primary_scheme["black"] primary_scheme["magenta"] = primary_scheme["pink"] diff --git a/unshackle/core/music/display.py b/unshackle/core/music/display.py index 08a8e747..19ceac8c 100644 --- a/unshackle/core/music/display.py +++ b/unshackle/core/music/display.py @@ -14,6 +14,7 @@ from io import BytesIO from typing import Any, Optional # RenderableType is rich's union of "things that can be printed to a console". +from rich import box from rich.console import Group, RenderableType from rich.padding import Padding from rich.panel import Panel @@ -145,7 +146,7 @@ def render_track_panel(rows: list[TrackRow], total: int) -> Panel: detail.append(" | ") detail.append("Hi-Res", style="gold1") node.add(detail, guide_style="bright_black") - return Panel(tree, title="Available Tracks") + return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") def render_album_header( diff --git a/unshackle/core/music/renderer.py b/unshackle/core/music/renderer.py index 65674aa0..5e9c5d4c 100644 --- a/unshackle/core/music/renderer.py +++ b/unshackle/core/music/renderer.py @@ -3,6 +3,7 @@ from __future__ import annotations import re from typing import Any, Optional +from rich import box from rich.console import Group, RenderableType from rich.markup import escape from rich.panel import Panel @@ -112,7 +113,7 @@ class MusicRenderer: suffix = "s" if hidden != 1 else "" tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black") - return Panel(tree, title="Available Tracks") + return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") def render_plan_header(self, plan: MusicDownloadPlan) -> Optional[Table]: title = plan.title @@ -174,7 +175,7 @@ class MusicRenderer: suffix = "s" if hidden != 1 else "" tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black") - return Panel(tree, title="Available Tracks") + return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") @staticmethod def _metadata_grid() -> Table: diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 1bd0d74a..51e44b2b 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import logging import re from pathlib import Path @@ -17,7 +18,7 @@ log = logging.getLogger("services") _raw = config.directories.services if not isinstance(_raw, list): _raw = [_raw] -# resolve in config order — priority IS list order: the first source to define a tag is the source, +# resolve in config order - priority IS list order: the first source to define a tag is the source, # later sources (local or repo) with the same tag are shadowed. List local last to make it a fallback. _service_dirs: list[Path] = [] DIRTY_REPOS: list[str] = [] @@ -26,7 +27,7 @@ for _entry in _raw: try: _resolved = resolve_service_repo(_entry) except DirtyServiceRepo as e: - # local edits in the clone — record it and let check_load_errors() exit cleanly + # local edits in the clone - record it and let check_load_errors() exit cleanly DIRTY_REPOS.append(redact_path(str(e.path))) _resolved = None if _resolved: @@ -59,12 +60,12 @@ def load_service(path: Path) -> object: try: module = import_module_by_path(path) except Exception as e: - raise RuntimeError(f"{tag}: failed to import — {type(e).__name__}: {e} ({path})") from e + raise RuntimeError(f"{tag}: failed to import - {type(e).__name__}: {e} ({path})") from e try: return getattr(module, tag) except AttributeError as e: raise RuntimeError( - f"{tag}: no class named '{tag}' found in {path} — the class name must match the directory name" + f"{tag}: no class named '{tag}' found in {path} - the class name must match the directory name" ) from e @@ -101,12 +102,12 @@ def check_load_errors() -> None: is rendered once by Click, without a traceback and without cascading through every command that imports this module. Duplicate services (a tag also served by an earlier, higher-priority source, which is ignored) are summarised here; the full - list — naming the path used and the duplicate ignored — is debug-only. + list - naming the path used and the duplicate ignored - is debug-only. """ if DIRTY_REPOS: joined = "\n".join(f" - {p}" for p in DIRTY_REPOS) raise click.ClickException( - "Service repo has local changes — refusing to refresh so your edits are not lost.\n" + "Service repo has local changes - refusing to refresh so your edits are not lost.\n" "Commit and push them to the upstream repo (or revert them), then retry:\n" + joined ) global _SUMMARY_LOGGED @@ -191,10 +192,28 @@ class Services(click.Group): raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}") if hasattr(service, "cli"): - return service.cli + cli = service.cli + cli.name = tag + doc = service.__doc__ + if doc and doc.strip() and cli.help in (None, "", doc): + cli.help = Services._docstring_help(doc) + return cli raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.") + @staticmethod + def _docstring_help(doc: str) -> str: + """Format a service docstring for Click help, one \\b per paragraph so Click keeps the layout. + + The first paragraph stays unprefixed: it is the summary line, and a \\b there + renders as a stray blank line and empties click's derived short help. + """ + doc = inspect.cleandoc(doc) + if "\b" in doc: + return doc + first, *rest = doc.split("\n\n") + return "\n\n".join([first] + [f"\b\n{p}" for p in rest]) + @staticmethod def _fetch_remote_services(ctx: click.Context) -> list[dict] | None: """Fetch the service list from the remote server (cached per process).""" @@ -219,9 +238,12 @@ class Services(click.Group): """Create a Click command for a remote service with server-provided options.""" svc_info = Services._fetch_remote_service_info(tag, ctx) short_help = svc_info.get("url") if svc_info else None + help_text = svc_info.get("help") if svc_info else None + if help_text: + help_text = Services._docstring_help(help_text) cli_params = svc_info.get("cli_params") if svc_info else None - @click.command(name=tag, short_help=short_help) + @click.command(name=tag, short_help=short_help, help=help_text) @click.argument("title", type=str) @click.pass_context def remote_cli(ctx: click.Context, title: str, **kwargs: object) -> object: diff --git a/unshackle/core/themes.py b/unshackle/core/themes.py new file mode 100644 index 00000000..56f760f8 --- /dev/null +++ b/unshackle/core/themes.py @@ -0,0 +1,231 @@ +""" +Named CLI color themes. + +Each palette maps the same role keys to colors: `text`/`text2` for body copy, `pink` for the +brand accent, the six ANSI hues, and the gray ramp used for rules and dim details. The active +palette drives both the rich Console theme (banner, logs, progress) and the click help screens, +so a single `theme` config value restyles the whole CLI. +""" + +from __future__ import annotations + +from rich_click import RichHelpConfiguration + +DEFAULT_THEME = "catppuccin-mocha" + +PALETTES: dict[str, dict[str, str]] = { + # Colors based on "CatppuccinMocha" from Gogh themes + "catppuccin-mocha": { + "bg": "rgb(30,30,46)", + "text": "rgb(205,214,244)", + "text2": "rgb(162,169,193)", # slightly darker + "black": "rgb(69,71,90)", + "bright_black": "rgb(88,91,112)", + "red": "rgb(243,139,168)", + "green": "rgb(166,227,161)", + "yellow": "rgb(249,226,175)", + "blue": "rgb(137,180,250)", + "pink": "rgb(245,194,231)", + "cyan": "rgb(148,226,213)", + "gray": "rgb(166,173,200)", + "bright_gray": "rgb(186,194,222)", + "dark_gray": "rgb(54,54,84)", + }, + # draculatheme.com spec; purple stands in for blue, comment for the dim grays + "dracula": { + "bg": "#282a36", + "text": "#f8f8f2", + "text2": "#b8bfd8", + "black": "#21222c", + "bright_black": "#6272a4", + "red": "#ff5555", + "green": "#50fa7b", + "yellow": "#f1fa8c", + "blue": "#bd93f9", + "pink": "#ff79c6", + "cyan": "#8be9fd", + "gray": "#6272a4", + "bright_gray": "#e9e9f4", + "dark_gray": "#44475a", + }, + # nordtheme.com: polar night / snow storm / frost / aurora + "nord": { + "bg": "#2e3440", + "text": "#d8dee9", + "text2": "#9aa4b8", + "black": "#3b4252", + "bright_black": "#4c566a", + "red": "#bf616a", + "green": "#a3be8c", + "yellow": "#ebcb8b", + "blue": "#81a1c1", + "pink": "#b48ead", + "cyan": "#88c0d0", + "gray": "#616e88", + "bright_gray": "#e5e9f0", + "dark_gray": "#434c5e", + }, + # github.com/morhetz/gruvbox dark, bright variants + "gruvbox": { + "bg": "#282828", + "text": "#ebdbb2", + "text2": "#bdae93", + "black": "#504945", + "bright_black": "#665c54", + "red": "#fb4934", + "green": "#b8bb26", + "yellow": "#fabd2f", + "blue": "#83a598", + "pink": "#d3869b", + "cyan": "#8ec07c", + "gray": "#928374", + "bright_gray": "#d5c4a1", + "dark_gray": "#3c3836", + }, + # Atom One Dark syntax palette + "one-dark": { + "bg": "#282c34", + "text": "#abb2bf", + "text2": "#818896", + "black": "#3b4048", + "bright_black": "#5c6370", + "red": "#e06c75", + "green": "#98c379", + "yellow": "#e5c07b", + "blue": "#61afef", + "pink": "#c678dd", + "cyan": "#56b6c2", + "gray": "#5c6370", + "bright_gray": "#c8ccd4", + "dark_gray": "#3e4451", + }, + # github.com/enkia/tokyo-night-vscode-theme, night variant + "tokyo-night": { + "bg": "#1a1b26", + "text": "#c0caf5", + "text2": "#a9b1d6", + "black": "#15161e", + "bright_black": "#414868", + "red": "#f7768e", + "green": "#9ece6a", + "yellow": "#e0af68", + "blue": "#7aa2f7", + "pink": "#bb9af7", + "cyan": "#7dcfff", + "gray": "#565f89", + "bright_gray": "#a9b1d6", + "dark_gray": "#292e42", + }, + # classic Monokai; purple stands in for pink, 66d9ef doubles as blue + "monokai": { + "bg": "#272822", + "text": "#f8f8f2", + "text2": "#cfcfc2", + "black": "#2e2e2e", + "bright_black": "#75715e", + "red": "#f92672", + "green": "#a6e22e", + "yellow": "#e6db74", + "blue": "#66d9ef", + "pink": "#ae81ff", + "cyan": "#a1efe4", + "gray": "#a59f85", + "bright_gray": "#cfcfc2", + "dark_gray": "#3e3d32", + }, + # flexoki.com dark, 400-level accents + "flexoki": { + "bg": "#100f0f", + "text": "#cecdc3", + "text2": "#878580", + "black": "#282726", + "bright_black": "#403e3c", + "red": "#d14d41", + "green": "#879a39", + "yellow": "#d0a215", + "blue": "#4385be", + "pink": "#ce5d97", + "cyan": "#3aa99f", + "gray": "#6f6e69", + "bright_gray": "#b7b5ac", + "dark_gray": "#343331", + }, + # ethanschoonover.com/solarized dark; magenta stands in for pink + "solarized-dark": { + "bg": "#002b36", + "text": "#839496", + "text2": "#586e75", + "black": "#073642", + "bright_black": "#586e75", + "red": "#dc322f", + "green": "#859900", + "yellow": "#b58900", + "blue": "#268bd2", + "pink": "#d33682", + "cyan": "#2aa198", + "gray": "#657b83", + "bright_gray": "#93a1a1", + "dark_gray": "#073642", + }, +} + +ALIASES = { + "default": DEFAULT_THEME, + "catppuccin": DEFAULT_THEME, + "mocha": DEFAULT_THEME, + "onedark": "one-dark", + "tokyonight": "tokyo-night", + "solarized": "solarized-dark", +} + + +def resolve_palette(name: object) -> dict[str, str] | None: + """Look up a palette by theme name or alias, case-insensitively. + + Takes any YAML scalar since config values are unvalidated; non-strings never match. + Returns a copy: console.py extends the active palette in place, and that must + not pollute the master definitions. + """ + key = str(name or "").strip().lower().replace("_", "-") + palette = PALETTES.get(ALIASES.get(key, key)) + return dict(palette) if palette else None + + +def build_help_config(palette: dict[str, str]) -> RichHelpConfiguration: + """Build the rich-click help styling for a palette.""" + return RichHelpConfiguration( + style_option=palette["text"], + style_switch=palette["green"], + style_argument=palette["cyan"], + style_command=palette["pink"], + style_metavar=palette["yellow"], + style_metavar_separator=f"dim {palette['gray']}", + style_usage=palette["yellow"], + style_usage_command=palette["text"], + style_helptext_first_line=palette["text"], + style_helptext=palette["text2"], + style_option_help=palette["text"], + style_command_help=palette["text"], + style_option_default=f"dim {palette['gray']}", + style_option_envvar=f"dim {palette['yellow']}", + style_required_short=palette["red"], + style_required_long=f"dim {palette['red']}", + style_errors_suggestion=palette["text2"], + style_aborted=palette["red"], + style_options_panel_box=None, + style_options_panel_border=palette["pink"], + style_options_panel_title_style=palette["pink"], + style_commands_panel_box=None, + style_commands_panel_border=palette["pink"], + style_commands_panel_title_style=palette["pink"], + style_errors_panel_box="SQUARE", + style_errors_panel_border=palette["red"], + style_options_table_box="HORIZONTALS", + style_options_table_show_lines=True, + style_options_table_border_style=palette["dark_gray"], + ) + + +def apply_help_theme(palette: dict[str, str]) -> None: + """Point the patched click help renderer at this palette (read at render time).""" + build_help_config(palette).dump_to_globals() diff --git a/uv.lock b/uv.lock index 76deca2d..f26ffb67 100644 --- a/uv.lock +++ b/uv.lock @@ -1828,6 +1828,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rich-click" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" }, +] + [[package]] name = "rlaphoenix-m3u8" version = "3.4.0" @@ -2157,6 +2171,7 @@ dependencies = [ { name = "pyyaml" }, { name = "requests", extra = ["socks"] }, { name = "rich" }, + { name = "rich-click" }, { name = "rlaphoenix-m3u8" }, { name = "rnet" }, { name = "ruamel-yaml" }, @@ -2224,6 +2239,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.1,<7" }, { name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" }, { name = "rich", specifier = ">=13.7.1,<15" }, + { name = "rich-click", specifier = ">=1.9.8" }, { name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" }, { name = "rnet", specifier = ">=2.4.2" }, { name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" },